detached ca3be6d

Merge pull request #73 from bnema/feat/recent-session-jump

feat(session): add recent session jumps
commit
ca3be6d2f18409b0b020ef428a7ed48b50d25d00
author
bnema · · 2026-07-11 14:03 UTC
committer
GitHub · · 2026-07-11 14:03 UTC
merge of
8b8c982 + 9d03f4b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
28 files · +1247 -866
M README.md +4 -3
@@ -108,7 +108,7 @@ code.focus-pane-down = FPD
108108 code.next-tab = NXT
109109 code.previous-tab = PVT
110110 code.back-session = BSK
111-code.forward-session = FSK
111+code.jump-recent-session = JRS
112112 code.session-picker = SSP
113113 code.visual-mode = VIS
114114 code.toggle-floating-pane = FLT
@@ -180,7 +180,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
180180
181181 Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.
182182
183-Successful commands are promoted near the top the next time the palette opens.
183+Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open.
184184
185185 | Code | Action |
186186 |---|---|
@@ -193,7 +193,8 @@ Successful commands are promoted near the top the next time the palette opens.
193193 | CLP | close focused pane |
194194 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |
195195 | NXT / PVT | switch to next / previous tab |
196-| BSK / FSK | move back / forward through recent sessions |
196+| BSK | toggle the previously active session |
197+| JRS N | jump directly to recent session rank N |
197198 | SSP | open session picker |
198199 | VIS | enter visual mode |
199200 | FLT | show or hide the current tab's floating terminal |
@@ -108,7 +108,7 @@ code.focus-pane-down = FPD
108 code.next-tab = NXT108 code.next-tab = NXT
109 code.previous-tab = PVT109 code.previous-tab = PVT
110 code.back-session = BSK110 code.back-session = BSK
111-code.forward-session = FSK111+code.jump-recent-session = JRS
112 code.session-picker = SSP112 code.session-picker = SSP
113 code.visual-mode = VIS113 code.visual-mode = VIS
114 code.toggle-floating-pane = FLT114 code.toggle-floating-pane = FLT
@@ -180,7 +180,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`;
180 180
181 Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.181 Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close.
182 182
183-Successful commands are promoted near the top the next time the palette opens.183+Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open.
184 184
185 | Code | Action |185 | Code | Action |
186 |---|---|186 |---|---|
@@ -193,7 +193,8 @@ Successful commands are promoted near the top the next time the palette opens.
193 | CLP | close focused pane |193 | CLP | close focused pane |
194 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |194 | FPL / FPR / FPU / FPD | focus pane left / right / up / down |
195 | NXT / PVT | switch to next / previous tab |195 | NXT / PVT | switch to next / previous tab |
196-| BSK / FSK | move back / forward through recent sessions |196+| BSK | toggle the previously active session |
197+| JRS N | jump directly to recent session rank N |
197 | SSP | open session picker |198 | SSP | open session picker |
198 | VIS | enter visual mode |199 | VIS | enter visual mode |
199 | FLT | show or hide the current tab's floating terminal |200 | FLT | show or hide the current tab's floating terminal |
M internal/usecase/command/command.go +46 -2
@@ -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] == "" || (len(args[0]) > 1 && args[0][0] == '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
@@ -20,16 +46,34 @@ type Context interface {
2046 NextTab() error
2147 PrevTab() error
2248 BackSession() error
23- ForwardSession() error
2449 Detach() error
2550 EnterVisualMode() error
2651 RenameSession() error
2752 RenameTab() error
2853 OpenSessionPicker() error
54+ JumpRecentSession(rank int) error
2955 }
3056
57+// Arguments declares whether a command accepts palette arguments.
58+type Arguments uint8
59+
60+const (
61+ ArgumentsNone Arguments = iota
62+ ArgumentsRequired
63+)
64+
65+// ContextHint describes optional contextual data needed to execute a command.
66+type ContextHint uint8
67+
68+const (
69+ ContextHintNone ContextHint = iota
70+ ContextHintRecentSessions
71+)
72+
3173 // Command describes an executable command.
3274 type Command struct {
3375 Slug, Code, Name, Desc string
34- Run func(Context) error
76+ Arguments Arguments
77+ ContextHint ContextHint
78+ Run func(Context, []string) error
3579 }
@@ -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] == "" || (len(args[0]) > 1 && args[0][0] == '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
@@ -20,16 +46,34 @@ type Context interface {
20 NextTab() error46 NextTab() error
21 PrevTab() error47 PrevTab() error
22 BackSession() error48 BackSession() error
23- ForwardSession() error
24 Detach() error49 Detach() error
25 EnterVisualMode() error50 EnterVisualMode() error
26 RenameSession() error51 RenameSession() error
27 RenameTab() error52 RenameTab() error
28 OpenSessionPicker() error53 OpenSessionPicker() error
54+ JumpRecentSession(rank int) error
29 }55 }
30 56
57+// Arguments declares whether a command accepts palette arguments.
58+type Arguments uint8
59+
60+const (
61+ ArgumentsNone Arguments = iota
62+ ArgumentsRequired
63+)
64+
65+// ContextHint describes optional contextual data needed to execute a command.
66+type ContextHint uint8
67+
68+const (
69+ ContextHintNone ContextHint = iota
70+ ContextHintRecentSessions
71+)
72+
31 // Command describes an executable command.73 // Command describes an executable command.
32 type Command struct {74 type Command struct {
33 Slug, Code, Name, Desc string75 Slug, Code, Name, Desc string
34- Run func(Context) error76+ Arguments Arguments
77+ ContextHint ContextHint
78+ Run func(Context, []string) error
35 }79 }
A internal/usecase/command/command_test.go +40 -0
@@ -0,0 +1,40 @@
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+ {"multi-digit", []string{"42"}, 42, false},
17+ {"overflow", []string{"999999999999999999999999999999999999"}, 0, true},
18+ {"leading zero", []string{"01"}, 0, true},
19+ {"multiple leading zeros", []string{"0001"}, 0, true},
20+ {"all zeros", []string{"00"}, 0, true},
21+ {"leading zero on multi-digit value", []string{"010"}, 0, true},
22+ {"zero", []string{"0"}, 0, true},
23+ {"negative", []string{"-1"}, 0, true},
24+ {"extra", []string{"1", "2"}, 0, true},
25+ {"space", []string{" 1"}, 0, true},
26+ } {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, err := ParsePositiveDecimal(tt.args)
29+ if tt.invalid {
30+ if !errors.Is(err, ErrInvalidArguments) {
31+ t.Fatalf("error = %v", err)
32+ }
33+ return
34+ }
35+ if err != nil || got != tt.want {
36+ t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err)
37+ }
38+ })
39+ }
40+}
@@ -0,0 +1,40 @@
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+ {"multi-digit", []string{"42"}, 42, false},
17+ {"overflow", []string{"999999999999999999999999999999999999"}, 0, true},
18+ {"leading zero", []string{"01"}, 0, true},
19+ {"multiple leading zeros", []string{"0001"}, 0, true},
20+ {"all zeros", []string{"00"}, 0, true},
21+ {"leading zero on multi-digit value", []string{"010"}, 0, true},
22+ {"zero", []string{"0"}, 0, true},
23+ {"negative", []string{"-1"}, 0, true},
24+ {"extra", []string{"1", "2"}, 0, true},
25+ {"space", []string{" 1"}, 0, true},
26+ } {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, err := ParsePositiveDecimal(tt.args)
29+ if tt.invalid {
30+ if !errors.Is(err, ErrInvalidArguments) {
31+ t.Fatalf("error = %v", err)
32+ }
33+ return
34+ }
35+ if err != nil || got != tt.want {
36+ t.Fatalf("ParsePositiveDecimal() = %d, %v", got, err)
37+ }
38+ })
39+ }
40+}
M internal/usecase/command/context_mock_test.go +22 -15
@@ -519,46 +519,53 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519519 return _c
520520 }
521521
522-// ForwardSession provides a mock function for the type MockContext
523-func (_mock *MockContext) ForwardSession() error {
524- ret := _mock.Called()
522+// JumpRecentSession provides a mock function for the type MockContext
523+func (_mock *MockContext) JumpRecentSession(rank int) error {
524+ ret := _mock.Called(rank)
525525
526526 if len(ret) == 0 {
527- panic("no return value specified for ForwardSession")
527+ panic("no return value specified for JumpRecentSession")
528528 }
529529
530530 var r0 error
531- if returnFunc, ok := ret.Get(0).(func() error); ok {
532- r0 = returnFunc()
531+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
532+ r0 = returnFunc(rank)
533533 } else {
534534 r0 = ret.Error(0)
535535 }
536536 return r0
537537 }
538538
539-// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'
540-type MockContext_ForwardSession_Call struct {
539+// MockContext_JumpRecentSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JumpRecentSession'
540+type MockContext_JumpRecentSession_Call struct {
541541 *mock.Call
542542 }
543543
544-// ForwardSession is a helper method to define mock.On call
545-func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {
546- return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}
544+// JumpRecentSession is a helper method to define mock.On call
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)}
547548 }
548549
549-func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {
550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call {
550551 _c.Call.Run(func(args mock.Arguments) {
551- run()
552+ var arg0 int
553+ if args[0] != nil {
554+ arg0 = args[0].(int)
555+ }
556+ run(
557+ arg0,
558+ )
552559 })
553560 return _c
554561 }
555562
556-func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {
563+func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_JumpRecentSession_Call {
557564 _c.Call.Return(err)
558565 return _c
559566 }
560567
561-func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {
568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call {
562569 _c.Call.Return(run)
563570 return _c
564571 }
@@ -519,46 +519,53 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont
519 return _c519 return _c
520 }520 }
521 521
522-// ForwardSession provides a mock function for the type MockContext522+// JumpRecentSession provides a mock function for the type MockContext
523-func (_mock *MockContext) ForwardSession() error {523+func (_mock *MockContext) JumpRecentSession(rank int) error {
524- ret := _mock.Called()524+ ret := _mock.Called(rank)
525 525
526 if len(ret) == 0 {526 if len(ret) == 0 {
527- panic("no return value specified for ForwardSession")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() error); ok {531+ if returnFunc, ok := ret.Get(0).(func(int) error); ok {
532- r0 = returnFunc()532+ r0 = returnFunc(rank)
533 } else {533 } else {
534 r0 = ret.Error(0)534 r0 = ret.Error(0)
535 }535 }
536 return r0536 return r0
537 }537 }
538 538
539-// MockContext_ForwardSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForwardSession'539+// MockContext_JumpRecentSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JumpRecentSession'
540-type MockContext_ForwardSession_Call struct {540+type MockContext_JumpRecentSession_Call struct {
541 *mock.Call541 *mock.Call
542 }542 }
543 543
544-// ForwardSession is a helper method to define mock.On call544+// JumpRecentSession is a helper method to define mock.On call
545-func (_e *MockContext_Expecter) ForwardSession() *MockContext_ForwardSession_Call {545+// - rank int
546- return &MockContext_ForwardSession_Call{Call: _e.mock.On("ForwardSession")}546+func (_e *MockContext_Expecter) JumpRecentSession(rank any) *MockContext_JumpRecentSession_Call {
547+ return &MockContext_JumpRecentSession_Call{Call: _e.mock.On("JumpRecentSession", rank)}
547 }548 }
548 549
549-func (_c *MockContext_ForwardSession_Call) Run(run func()) *MockContext_ForwardSession_Call {550+func (_c *MockContext_JumpRecentSession_Call) Run(run func(rank int)) *MockContext_JumpRecentSession_Call {
550 _c.Call.Run(func(args mock.Arguments) {551 _c.Call.Run(func(args mock.Arguments) {
551- run()552+ var arg0 int
553+ if args[0] != nil {
554+ arg0 = args[0].(int)
555+ }
556+ run(
557+ arg0,
558+ )
552 })559 })
553 return _c560 return _c
554 }561 }
555 562
556-func (_c *MockContext_ForwardSession_Call) Return(err error) *MockContext_ForwardSession_Call {563+func (_c *MockContext_JumpRecentSession_Call) Return(err error) *MockContext_JumpRecentSession_Call {
557 _c.Call.Return(err)564 _c.Call.Return(err)
558 return _c565 return _c
559 }566 }
560 567
561-func (_c *MockContext_ForwardSession_Call) RunAndReturn(run func() error) *MockContext_ForwardSession_Call {568+func (_c *MockContext_JumpRecentSession_Call) RunAndReturn(run func(rank int) error) *MockContext_JumpRecentSession_Call {
562 _c.Call.Return(run)569 _c.Call.Return(run)
563 return _c570 return _c
564 }571 }
M internal/usecase/command/registry.go +30 -24
@@ -5,30 +5,36 @@ import "strings"
55 // Registry returns all commands in display order.
66 func Registry() []Command {
77 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }},
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},
22- {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
23- {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
24- {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
25- {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
26- {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
27- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
28- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) 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) error { return ctx.RenameSession() }},
30- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
31- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},
9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},
10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},
11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},
12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},
13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},
14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},
15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},
16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},
17+ {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},
18+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},
19+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},
20+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},
21+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},
22+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }},
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() }},
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 {
27+ rank, err := ParsePositiveDecimal(args)
28+ if err != nil {
29+ return err
30+ }
31+ return ctx.JumpRecentSession(rank)
32+ }},
33+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
34+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
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() }},
36+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
37+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},
3238 }
3339
3440 return commands
@@ -5,30 +5,36 @@ import "strings"
5 // Registry returns all commands in display order.5 // Registry returns all commands in display order.
6 func Registry() []Command {6 func Registry() []Command {
7 commands := []Command{7 commands := []Command{
8- {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},8+ {Slug: "new-tab", Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context, _ []string) error { return ctx.CreateTab() }},
9- {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},9+ {Slug: "new-session", Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context, _ []string) error { return ctx.CreateSession() }},
10- {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},10+ {Slug: "close-tab", Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context, _ []string) error { return ctx.CloseTab() }},
11- {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }},11+ {Slug: "split-right", Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context, _ []string) error { return ctx.SplitRight() }},
12- {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }},12+ {Slug: "split-left", Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context, _ []string) error { return ctx.SplitLeft() }},
13- {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }},13+ {Slug: "split-up", Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context, _ []string) error { return ctx.SplitUp() }},
14- {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},14+ {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context, _ []string) error { return ctx.SplitDown() }},
15- {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},15+ {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context, _ []string) error { return ctx.StackPane() }},
16- {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},16+ {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context, _ []string) error { return ctx.ToggleStack() }},
17- {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }},17+ {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }},
18- {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},18+ {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context, _ []string) error { return ctx.ClosePane() }},
19- {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},19+ {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneLeft() }},
20- {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},20+ {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }},
21- {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }},21+ {Slug: "focus-pane-up", Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }},
22- {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},22+ {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }},
23- {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) 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) 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: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) 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: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},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- {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},27+ rank, err := ParsePositiveDecimal(args)
28- {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},28+ if err != nil {
29- {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},29+ return err
30- {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},30+ }
31- {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},31+ return ctx.JumpRecentSession(rank)
32+ }},
33+ {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context, _ []string) error { return ctx.OpenSessionPicker() }},
34+ {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context, _ []string) error { return ctx.EnterVisualMode() }},
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() }},
36+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context, _ []string) error { return ctx.RenameTab() }},
37+ {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context, _ []string) error { return ctx.Detach() }},
32 }38 }
33 39
34 return commands40 return commands
M internal/usecase/command/registry_test.go +7 -3
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -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: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().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); 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 })
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -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: "FSK", expect: func(ctx *MockContext) { ctx.EXPECT().ForwardSession().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); 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/client.go +12 -20
@@ -59,12 +59,9 @@ type attachedClient struct {
5959 theme themeui.Theme
6060 clientTheme themeui.Theme
6161 lastCursor cursorOut
62- recentNav recentSessionNavigator
63- // historyNavMu protects historyNav and historyNavTimer. When paint needs
64- // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
65- historyNavMu sync.Mutex
66- historyNav historyNavDisplay
67- historyNavTimer pendingByteTimer
62+ // previousSession is guarded independently. It is retained through temporary
63+ // setSession(nil) hand-offs and cleared only on terminal teardown.
64+ previousSession Guarded[*session]
6865 linkMu sync.Mutex
6966 sendMu sync.Mutex
7067 }
@@ -125,22 +122,12 @@ func (ac *attachedClient) initOverlays() {
125122
126123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
127124
128-func (ac *attachedClient) setSession(sess *session) {
129- if sess == nil {
130- ac.clearHistoryNav()
131- }
132- ac.sess.Set(sess)
133-}
125+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
134126
135-func (ac *attachedClient) clearHistoryNav() {
136- if ac == nil {
137- return
127+func (ac *attachedClient) clearPreviousSession() {
128+ if ac != nil {
129+ ac.previousSession.Set(nil)
138130 }
139- ac.historyNavMu.Lock()
140- ac.historyNav.gen++
141- ac.historyNavTimer.stop()
142- ac.historyNav.clear()
143- ac.historyNavMu.Unlock()
144131 }
145132
146133 func (ac *attachedClient) getTheme() themeui.Theme {
@@ -435,7 +422,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
435422 return
436423 }
437424 old.cancelResizePaint()
425+
438426 d.unregisterPreview(old)
427+ old.clearPreviousSession()
439428 old.setSession(nil)
440429 d.log.Info("client displaced")
441430 // Async + bounded: a dead or wedged old client must not stall the new
@@ -554,7 +543,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
554543 d.log.Info("client parked", "session", sess.name)
555544 return
556545 }
546+
557547 d.resetScreenDefaultColors(sess)
548+ ac.clearPreviousSession()
558549 if explicit {
559550 // Synchronous so the ack is delivered before the transport closes
560551 // (the client is actively awaiting it), but deadline-bounded so a
@@ -653,6 +644,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
653644 return
654645 }
655646 d.resetScreenDefaultColors(sess)
647+ ac.clearPreviousSession()
656648 _ = ac.closeCapturedTransport(failed)
657649 d.log.Warn("detached client after send error", "session", sess.name)
658650 }
@@ -59,12 +59,9 @@ type attachedClient struct {
59 theme themeui.Theme59 theme themeui.Theme
60 clientTheme themeui.Theme60 clientTheme themeui.Theme
61 lastCursor cursorOut61 lastCursor cursorOut
62- recentNav recentSessionNavigator62+ // previousSession is guarded independently. It is retained through temporary
63- // historyNavMu protects historyNav and historyNavTimer. When paint needs63+ // setSession(nil) hand-offs and cleared only on terminal teardown.
64- // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.64+ previousSession Guarded[*session]
65- historyNavMu sync.Mutex
66- historyNav historyNavDisplay
67- historyNavTimer pendingByteTimer
68 linkMu sync.Mutex65 linkMu sync.Mutex
69 sendMu sync.Mutex66 sendMu sync.Mutex
70 }67 }
@@ -125,22 +122,12 @@ func (ac *attachedClient) initOverlays() {
125 122
126 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
127 124
128-func (ac *attachedClient) setSession(sess *session) {125+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
129- if sess == nil {
130- ac.clearHistoryNav()
131- }
132- ac.sess.Set(sess)
133-}
134 126
135-func (ac *attachedClient) clearHistoryNav() {127+func (ac *attachedClient) clearPreviousSession() {
136- if ac == nil {128+ if ac != nil {
137- return129+ ac.previousSession.Set(nil)
138 }130 }
139- ac.historyNavMu.Lock()
140- ac.historyNav.gen++
141- ac.historyNavTimer.stop()
142- ac.historyNav.clear()
143- ac.historyNavMu.Unlock()
144 }131 }
145 132
146 func (ac *attachedClient) getTheme() themeui.Theme {133 func (ac *attachedClient) getTheme() themeui.Theme {
@@ -435,7 +422,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
435 return422 return
436 }423 }
437 old.cancelResizePaint()424 old.cancelResizePaint()
425+
438 d.unregisterPreview(old)426 d.unregisterPreview(old)
427+ old.clearPreviousSession()
439 old.setSession(nil)428 old.setSession(nil)
440 d.log.Info("client displaced")429 d.log.Info("client displaced")
441 // Async + bounded: a dead or wedged old client must not stall the new430 // Async + bounded: a dead or wedged old client must not stall the new
@@ -554,7 +543,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
554 d.log.Info("client parked", "session", sess.name)543 d.log.Info("client parked", "session", sess.name)
555 return544 return
556 }545 }
546+
557 d.resetScreenDefaultColors(sess)547 d.resetScreenDefaultColors(sess)
548+ ac.clearPreviousSession()
558 if explicit {549 if explicit {
559 // Synchronous so the ack is delivered before the transport closes550 // Synchronous so the ack is delivered before the transport closes
560 // (the client is actively awaiting it), but deadline-bounded so a551 // (the client is actively awaiting it), but deadline-bounded so a
@@ -653,6 +644,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
653 return644 return
654 }645 }
655 d.resetScreenDefaultColors(sess)646 d.resetScreenDefaultColors(sess)
647+ ac.clearPreviousSession()
656 _ = ac.closeCapturedTransport(failed)648 _ = ac.closeCapturedTransport(failed)
657 d.log.Warn("detached client after send error", "session", sess.name)649 d.log.Warn("detached client after send error", "session", sess.name)
658 }650 }
M internal/usecase/daemon/daemon.go +3 -0
@@ -114,6 +114,9 @@ type Daemon struct {
114114
115115 paletteRecentMu sync.Mutex
116116 paletteRecent []string
117+ // beforeRecentSessionHandoff is a deterministic test seam for the narrow
118+ // interval between JRS validation and its committed hand-off.
119+ beforeRecentSessionHandoff func()
117120
118121 ptys ports.PTYFactory
119122 clock ports.Clock
@@ -114,6 +114,9 @@ type Daemon struct {
114 114
115 paletteRecentMu sync.Mutex115 paletteRecentMu sync.Mutex
116 paletteRecent []string116 paletteRecent []string
117+ // beforeRecentSessionHandoff is a deterministic test seam for the narrow
118+ // interval between JRS validation and its committed hand-off.
119+ beforeRecentSessionHandoff func()
117 120
118 ptys ports.PTYFactory121 ptys ports.PTYFactory
119 clock ports.Clock122 clock ports.Clock
M internal/usecase/daemon/input_test.go +107 -294
@@ -17,6 +17,7 @@ import (
1717 scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/keys"
1919 "github.com/bnema/vev/internal/usecase/layout"
20+ "github.com/bnema/vev/internal/usecase/picker"
2021 )
2122
2223 // --- test doubles -----------------------------------------------------------
@@ -415,321 +416,133 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
415416 }
416417 }
417418
418-func TestPaletteBackForwardSession(t *testing.T) {
419+func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
419420 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
420421 defer releaseAll(releases)
421422 recent := d.sessions[domain.SessionID("recent")]
422- older := d.sessions[domain.SessionID("older")]
423423
424- runPaletteCommand(t, d, current, ac, "BSK")
425- require.Same(t, recent, ac.currentSession())
426-
427- runPaletteCommand(t, d, recent, ac, "BSK")
428- require.Same(t, older, ac.currentSession())
429-
430- runPaletteCommand(t, d, older, ac, "FSK")
431- require.Same(t, recent, ac.currentSession())
432-
433- runPaletteCommand(t, d, recent, ac, "FSK")
434- require.Same(t, current, ac.currentSession())
435-}
436-
437-func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
438- t.Run("forward from current does not wrap", func(t *testing.T) {
439- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
440- defer releaseAll(releases)
441-
442- runPaletteCommand(t, d, current, ac, "FSK")
443- require.Same(t, current, ac.currentSession())
444- })
445-
446- t.Run("back with no MRU does not move", func(t *testing.T) {
447- p, release := newBlockingPTY(t)
448- defer release()
449- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
450-
451- runPaletteCommand(t, d, sess, ac, "BSK")
452- require.Same(t, sess, ac.currentSession())
453- })
454-
455- t.Run("back at oldest does not wrap", func(t *testing.T) {
456- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
457- defer releaseAll(releases)
458- older := d.sessions[domain.SessionID("older")]
459-
460- runPaletteCommand(t, d, current, ac, "BSK")
424+ // Picker-style successful transition records its origin.
425+ d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})
426+ for _, sess := range []*session{current, recent, current} {
461427 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
462- require.Same(t, older, ac.currentSession())
463-
464- runPaletteCommand(t, d, older, ac, "BSK")
465- require.Same(t, older, ac.currentSession())
466- })
467-}
468-
469-func TestHistoryNavDisplay(t *testing.T) {
470- ids := []domain.SessionID{"current", "recent", "older"}
471- var h historyNavDisplay
472-
473- h.set(ids, 1, 7)
474- ids[1] = "mutated"
475- require.True(t, h.active())
476- require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
477- require.Equal(t, 1, h.index)
478- require.Equal(t, uint64(7), h.gen)
479-
480- h.index = -1
481- require.False(t, h.active())
482- h.index = len(h.ids)
483- require.False(t, h.active())
484-
485- h.clear()
486- require.False(t, h.active())
487- require.Empty(t, h.ids)
488- require.Zero(t, h.index)
489- require.Equal(t, uint64(7), h.gen)
490-}
491-
492-func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
493- require.True(t, isHistoryNavigationCommand("back-session"))
494- require.True(t, isHistoryNavigationCommand("forward-session"))
495- require.False(t, isHistoryNavigationCommand("BSK"))
496- require.False(t, isHistoryNavigationCommand("FSK"))
497- require.False(t, isHistoryNavigationCommand("next-tab"))
498-}
499-
500-func TestHistoryNavActivation(t *testing.T) {
501- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
502- defer releaseAll(releases)
503- recent := d.sessions[domain.SessionID("recent")]
504- older := d.sessions[domain.SessionID("older")]
505-
506- runPaletteCommand(t, d, current, ac, "BSK")
507- require.Same(t, recent, ac.currentSession())
508- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
509- require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
510-
511- runPaletteCommand(t, d, recent, ac, "BSK")
512- require.Same(t, older, ac.currentSession())
513- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
514-
515- runPaletteCommand(t, d, older, ac, "FSK")
516- require.Same(t, recent, ac.currentSession())
517- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
518-}
519-
520-func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
521- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
522- defer releaseAll(releases)
523-
524- runPaletteCommand(t, d, current, ac, "FSK")
525-
526- require.Same(t, current, ac.currentSession())
527- ac.historyNavMu.Lock()
528- active := ac.historyNav.active()
529- ac.historyNavMu.Unlock()
530- require.False(t, active)
531-}
532-
533-func TestHistoryNavTimeoutExpires(t *testing.T) {
534- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
535- defer releaseAll(releases)
536- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
537- d.clock = clock
538-
539- runPaletteCommand(t, d, current, ac, "BSK")
540- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
541- timer := <-clock.timers
542- drainOutputFrames(sends)
543- timer.fire()
544-
545- require.Eventually(t, func() bool {
546- ac.historyNavMu.Lock()
547- defer ac.historyNavMu.Unlock()
548- return !ac.historyNav.active()
549- }, time.Second, time.Millisecond)
550- require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
428+ require.Same(t, sess, ac.currentSession())
429+ }
551430 }
552431
553-func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
432+func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
554433 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
555434 defer releaseAll(releases)
556- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
557- d.clock = clock
558-
559- runPaletteCommand(t, d, current, ac, "BSK")
560- staleTimer := <-clock.timers
561- ac.clearHistoryNav()
562- runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
563- <-clock.timers
564-
565- staleTimer.fire()
566-
567- require.Eventually(t, func() bool {
568- ac.historyNavMu.Lock()
569- defer ac.historyNavMu.Unlock()
570- return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
571- }, time.Second, time.Millisecond)
572-}
573-
574-func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
575- t.Run("detach stops stale timer repaint", func(t *testing.T) {
576- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
577- defer releaseAll(releases)
578- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
579- d.clock = clock
580-
581- runPaletteCommand(t, d, current, ac, "BSK")
582- timer := <-clock.timers
583- recent := ac.currentSession()
584- d.clientGone(recent, ac, ac.transport(), true)
585- drainOutputFrames(sends)
586- timer.fire()
587-
588- requireNoHistoryNav(t, ac)
589- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
590- })
591-
592- t.Run("replacement stops stale timer repaint", func(t *testing.T) {
593- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
594- defer releaseAll(releases)
595- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
596- d.clock = clock
597-
598- runPaletteCommand(t, d, current, ac, "BSK")
599- timer := <-clock.timers
600- d.detachReplacedClient(ac)
601- drainOutputFrames(sends)
602- timer.fire()
603-
604- requireNoHistoryNav(t, ac)
605- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
606- })
435+ for _, tc := range []struct {
436+ name string
437+ prepare func()
438+ }{
439+ {name: "no target", prepare: func() {}},
440+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},
441+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
442+ } {
443+ t.Run(tc.name, func(t *testing.T) {
444+ ac.clearPreviousSession()
445+ tc.prepare()
446+ runPaletteCommand(t, d, current, ac, "BSK")
447+ require.Same(t, current, ac.currentSession())
448+ if tc.name != "no target" {
449+ require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
450+ }
451+ })
452+ }
607453 }
608454
609-func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
610- t.Run("invalid frozen trail clears", func(t *testing.T) {
611- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
612- defer releaseAll(releases)
613-
614- runPaletteCommand(t, d, current, ac, "BSK")
615- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
616- d.mu.Lock()
617- delete(d.sessions, domain.SessionID("older"))
618- d.mu.Unlock()
619-
620- require.Nil(t, d.historyNavBarState(ac))
621- requireNoHistoryNav(t, ac)
622- })
623-
624- t.Run("non history palette command clears frozen trail", func(t *testing.T) {
625- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
626- defer releaseAll(releases)
627-
628- runPaletteCommand(t, d, current, ac, "BSK")
629- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
630- runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
631-
632- requireNoHistoryNav(t, ac)
633- })
634-}
455+func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
456+ ac := &attachedClient{}
457+ stale := &session{id: "stale"}
458+ updated := &session{id: "updated"}
459+ ac.previousSession.Set(stale)
635460
636-func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
637- t.Helper()
638- ac.historyNavMu.Lock()
639- defer ac.historyNavMu.Unlock()
640- require.True(t, ac.historyNav.active())
641- require.Equal(t, want, ac.historyNav.ids)
642- require.Equal(t, wantIndex, ac.historyNav.index)
643-}
461+ // Model a completed hand-off between observing a stale target and clearing
462+ // it. The conditional clear must not erase the newer toggle destination.
463+ ac.previousSession.Set(updated)
464+ ac.clearPreviousSessionIf(stale)
644465
645-func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
646- t.Helper()
647- ac.historyNavMu.Lock()
648- defer ac.historyNavMu.Unlock()
649- require.False(t, ac.historyNav.active())
466+ require.Same(t, updated, ac.previousSession.Get())
650467 }
651468
652-func drainOutputFrames(sends chan ports.Frame) int {
653- count := 0
654- for {
655- select {
656- case f := <-sends:
657- if f.Type == ports.MsgOutput {
658- count++
469+func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470+ for _, tc := range []struct {
471+ name string
472+ target func(*session, *session) picker.Target
473+ wantCurrent string
474+ wantPrev string
475+ }{
476+ {
477+ name: "picker cross-session switch records origin",
478+ target: func(_ *session, recent *session) picker.Target {
479+ return picker.Target{Session: recent.id, TabIndex: -1}
480+ },
481+ wantCurrent: "recent",
482+ wantPrev: "current",
483+ },
484+ {
485+ name: "attention cross-session switch records origin",
486+ target: func(_ *session, recent *session) picker.Target {
487+ return picker.Target{Session: recent.id, TabIndex: 0}
488+ },
489+ wantCurrent: "recent",
490+ wantPrev: "current",
491+ },
492+ {
493+ name: "same-session tab switch does not record",
494+ target: func(current *session, _ *session) picker.Target {
495+ return picker.Target{Session: current.id, TabIndex: 0}
496+ },
497+ wantCurrent: "current",
498+ wantPrev: "",
499+ },
500+ {
501+ name: "missing target does not record",
502+ target: func(_ *session, _ *session) picker.Target {
503+ return picker.Target{Session: "missing", TabIndex: -1}
504+ },
505+ wantCurrent: "current",
506+ wantPrev: "",
507+ },
508+ } {
509+ t.Run(tc.name, func(t *testing.T) {
510+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
511+ defer releaseAll(releases)
512+ recent := d.sessions[domain.SessionID("recent")]
513+
514+ d.switchToTarget(current, ac, tc.target(current, recent))
515+ require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
516+ if tc.wantPrev == "" {
517+ require.Nil(t, ac.previousSession.Get())
518+ return
659519 }
660- default:
661- return count
662- }
520+ require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id)
521+ })
663522 }
664523 }
665524
666-type historyNavTestClock struct {
667- timers chan *historyNavTestTimer
668-}
669-
670-func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
671-
672-func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
673- t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
674- c.timers <- t
675- return t
676-}
525+func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
526+ for _, tc := range []struct {
527+ name string
528+ teardown func(*Daemon, *session, *attachedClient)
529+ }{
530+ {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }},
531+ {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
532+ {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) {
533+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
534+ }},
535+ } {
536+ t.Run(tc.name, func(t *testing.T) {
537+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
538+ defer releaseAll(releases)
677539
678-type historyNavTestTimer struct {
679- ch chan time.Time
680-}
540+ ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
541+ tc.teardown(d, current, ac)
681542
682-func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
683-func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
684-func (t *historyNavTestTimer) Stop() bool { return true }
685-func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
686-
687-func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
688- t.Run("forward skips a killed intermediate session", func(t *testing.T) {
689- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
690- defer releaseAll(releases)
691- recent := d.sessions[domain.SessionID("recent")]
692- older := d.sessions[domain.SessionID("older")]
693-
694- runPaletteCommand(t, d, current, ac, "BSK")
695- runPaletteCommand(t, d, recent, ac, "BSK")
696- require.Same(t, older, ac.currentSession())
697-
698- d.mu.Lock()
699- delete(d.sessions, recent.id)
700- d.mu.Unlock()
701-
702- runPaletteCommand(t, d, older, ac, "FSK")
703- require.Same(t, current, ac.currentSession())
704- })
705-
706- t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
707- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
708- defer releaseAll(releases)
709- p, release := newBlockingPTY(t)
710- defer release()
711- newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
712- newer.mruAt.Store(25)
713- d.mu.Lock()
714- d.sessions[newer.id] = newer
715- d.mu.Unlock()
716-
717- runPaletteCommand(t, d, current, ac, "BSK")
718- require.Same(t, newer, ac.currentSession())
719- })
720-
721- t.Run("failed switch does not advance navigator index", func(t *testing.T) {
722- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
723- defer releaseAll(releases)
724- current.mu.Lock()
725- current.client = nil
726- current.mu.Unlock()
727-
728- runPaletteCommand(t, d, current, ac, "BSK")
729-
730- require.Same(t, current, ac.currentSession())
731- require.Zero(t, ac.recentNav.index)
732- })
543+ require.Nil(t, ac.previousSession.Get())
544+ })
545+ }
733546 }
734547
735548 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
@@ -17,6 +17,7 @@ import (
17 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/keys"18 "github.com/bnema/vev/internal/usecase/keys"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
20+ "github.com/bnema/vev/internal/usecase/picker"
20 )21 )
21 22
22 // --- test doubles -----------------------------------------------------------23 // --- test doubles -----------------------------------------------------------
@@ -415,321 +416,133 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
415 }416 }
416 }417 }
417 418
418-func TestPaletteBackForwardSession(t *testing.T) {419+func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
419 d, current, ac, _, releases := newRecentNavigationTestSessions(t)420 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
420 defer releaseAll(releases)421 defer releaseAll(releases)
421 recent := d.sessions[domain.SessionID("recent")]422 recent := d.sessions[domain.SessionID("recent")]
422- older := d.sessions[domain.SessionID("older")]
423 423
424- runPaletteCommand(t, d, current, ac, "BSK")424+ // Picker-style successful transition records its origin.
425- require.Same(t, recent, ac.currentSession())425+ d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})
426-426+ for _, sess := range []*session{current, recent, current} {
427- runPaletteCommand(t, d, recent, ac, "BSK")
428- require.Same(t, older, ac.currentSession())
429-
430- runPaletteCommand(t, d, older, ac, "FSK")
431- require.Same(t, recent, ac.currentSession())
432-
433- runPaletteCommand(t, d, recent, ac, "FSK")
434- require.Same(t, current, ac.currentSession())
435-}
436-
437-func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
438- t.Run("forward from current does not wrap", func(t *testing.T) {
439- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
440- defer releaseAll(releases)
441-
442- runPaletteCommand(t, d, current, ac, "FSK")
443- require.Same(t, current, ac.currentSession())
444- })
445-
446- t.Run("back with no MRU does not move", func(t *testing.T) {
447- p, release := newBlockingPTY(t)
448- defer release()
449- d, sess, ac, _ := newManualSessionWithPTYs(t, p)
450-
451- runPaletteCommand(t, d, sess, ac, "BSK")
452- require.Same(t, sess, ac.currentSession())
453- })
454-
455- t.Run("back at oldest does not wrap", func(t *testing.T) {
456- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
457- defer releaseAll(releases)
458- older := d.sessions[domain.SessionID("older")]
459-
460- runPaletteCommand(t, d, current, ac, "BSK")
461 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")427 runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
462- require.Same(t, older, ac.currentSession())428+ require.Same(t, sess, ac.currentSession())
463-429+ }
464- runPaletteCommand(t, d, older, ac, "BSK")
465- require.Same(t, older, ac.currentSession())
466- })
467-}
468-
469-func TestHistoryNavDisplay(t *testing.T) {
470- ids := []domain.SessionID{"current", "recent", "older"}
471- var h historyNavDisplay
472-
473- h.set(ids, 1, 7)
474- ids[1] = "mutated"
475- require.True(t, h.active())
476- require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
477- require.Equal(t, 1, h.index)
478- require.Equal(t, uint64(7), h.gen)
479-
480- h.index = -1
481- require.False(t, h.active())
482- h.index = len(h.ids)
483- require.False(t, h.active())
484-
485- h.clear()
486- require.False(t, h.active())
487- require.Empty(t, h.ids)
488- require.Zero(t, h.index)
489- require.Equal(t, uint64(7), h.gen)
490-}
491-
492-func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) {
493- require.True(t, isHistoryNavigationCommand("back-session"))
494- require.True(t, isHistoryNavigationCommand("forward-session"))
495- require.False(t, isHistoryNavigationCommand("BSK"))
496- require.False(t, isHistoryNavigationCommand("FSK"))
497- require.False(t, isHistoryNavigationCommand("next-tab"))
498-}
499-
500-func TestHistoryNavActivation(t *testing.T) {
501- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
502- defer releaseAll(releases)
503- recent := d.sessions[domain.SessionID("recent")]
504- older := d.sessions[domain.SessionID("older")]
505-
506- runPaletteCommand(t, d, current, ac, "BSK")
507- require.Same(t, recent, ac.currentSession())
508- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
509- require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
510-
511- runPaletteCommand(t, d, recent, ac, "BSK")
512- require.Same(t, older, ac.currentSession())
513- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
514-
515- runPaletteCommand(t, d, older, ac, "FSK")
516- require.Same(t, recent, ac.currentSession())
517- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
518-}
519-
520-func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
521- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
522- defer releaseAll(releases)
523-
524- runPaletteCommand(t, d, current, ac, "FSK")
525-
526- require.Same(t, current, ac.currentSession())
527- ac.historyNavMu.Lock()
528- active := ac.historyNav.active()
529- ac.historyNavMu.Unlock()
530- require.False(t, active)
531-}
532-
533-func TestHistoryNavTimeoutExpires(t *testing.T) {
534- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
535- defer releaseAll(releases)
536- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
537- d.clock = clock
538-
539- runPaletteCommand(t, d, current, ac, "BSK")
540- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
541- timer := <-clock.timers
542- drainOutputFrames(sends)
543- timer.fire()
544-
545- require.Eventually(t, func() bool {
546- ac.historyNavMu.Lock()
547- defer ac.historyNavMu.Unlock()
548- return !ac.historyNav.active()
549- }, time.Second, time.Millisecond)
550- require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
551 }430 }
552 431
553-func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {432+func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
554 d, current, ac, _, releases := newRecentNavigationTestSessions(t)433 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
555 defer releaseAll(releases)434 defer releaseAll(releases)
556- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}435+ for _, tc := range []struct {
557- d.clock = clock436+ name string
558-437+ prepare func()
559- runPaletteCommand(t, d, current, ac, "BSK")438+ }{
560- staleTimer := <-clock.timers439+ {name: "no target", prepare: func() {}},
561- ac.clearHistoryNav()440+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},
562- runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")441+ {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
563- <-clock.timers442+ } {
564-443+ t.Run(tc.name, func(t *testing.T) {
565- staleTimer.fire()444+ ac.clearPreviousSession()
566-445+ tc.prepare()
567- require.Eventually(t, func() bool {446+ runPaletteCommand(t, d, current, ac, "BSK")
568- ac.historyNavMu.Lock()447+ require.Same(t, current, ac.currentSession())
569- defer ac.historyNavMu.Unlock()448+ if tc.name != "no target" {
570- return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1449+ require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
571- }, time.Second, time.Millisecond)450+ }
572-}451+ })
573-452+ }
574-func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
575- t.Run("detach stops stale timer repaint", func(t *testing.T) {
576- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
577- defer releaseAll(releases)
578- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
579- d.clock = clock
580-
581- runPaletteCommand(t, d, current, ac, "BSK")
582- timer := <-clock.timers
583- recent := ac.currentSession()
584- d.clientGone(recent, ac, ac.transport(), true)
585- drainOutputFrames(sends)
586- timer.fire()
587-
588- requireNoHistoryNav(t, ac)
589- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
590- })
591-
592- t.Run("replacement stops stale timer repaint", func(t *testing.T) {
593- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
594- defer releaseAll(releases)
595- clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
596- d.clock = clock
597-
598- runPaletteCommand(t, d, current, ac, "BSK")
599- timer := <-clock.timers
600- d.detachReplacedClient(ac)
601- drainOutputFrames(sends)
602- timer.fire()
603-
604- requireNoHistoryNav(t, ac)
605- require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
606- })
607 }453 }
608 454
609-func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {455+func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
610- t.Run("invalid frozen trail clears", func(t *testing.T) {456+ ac := &attachedClient{}
611- d, current, ac, _, releases := newRecentNavigationTestSessions(t)457+ stale := &session{id: "stale"}
612- defer releaseAll(releases)458+ updated := &session{id: "updated"}
613-459+ ac.previousSession.Set(stale)
614- runPaletteCommand(t, d, current, ac, "BSK")
615- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
616- d.mu.Lock()
617- delete(d.sessions, domain.SessionID("older"))
618- d.mu.Unlock()
619-
620- require.Nil(t, d.historyNavBarState(ac))
621- requireNoHistoryNav(t, ac)
622- })
623-
624- t.Run("non history palette command clears frozen trail", func(t *testing.T) {
625- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
626- defer releaseAll(releases)
627-
628- runPaletteCommand(t, d, current, ac, "BSK")
629- requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
630- runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
631-
632- requireNoHistoryNav(t, ac)
633- })
634-}
635 460
636-func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {461+ // Model a completed hand-off between observing a stale target and clearing
637- t.Helper()462+ // it. The conditional clear must not erase the newer toggle destination.
638- ac.historyNavMu.Lock()463+ ac.previousSession.Set(updated)
639- defer ac.historyNavMu.Unlock()464+ ac.clearPreviousSessionIf(stale)
640- require.True(t, ac.historyNav.active())
641- require.Equal(t, want, ac.historyNav.ids)
642- require.Equal(t, wantIndex, ac.historyNav.index)
643-}
644 465
645-func requireNoHistoryNav(t *testing.T, ac *attachedClient) {466+ require.Same(t, updated, ac.previousSession.Get())
646- t.Helper()
647- ac.historyNavMu.Lock()
648- defer ac.historyNavMu.Unlock()
649- require.False(t, ac.historyNav.active())
650 }467 }
651 468
652-func drainOutputFrames(sends chan ports.Frame) int {469+func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
653- count := 0470+ for _, tc := range []struct {
654- for {471+ name string
655- select {472+ target func(*session, *session) picker.Target
656- case f := <-sends:473+ wantCurrent string
657- if f.Type == ports.MsgOutput {474+ wantPrev string
658- count++475+ }{
476+ {
477+ name: "picker cross-session switch records origin",
478+ target: func(_ *session, recent *session) picker.Target {
479+ return picker.Target{Session: recent.id, TabIndex: -1}
480+ },
481+ wantCurrent: "recent",
482+ wantPrev: "current",
483+ },
484+ {
485+ name: "attention cross-session switch records origin",
486+ target: func(_ *session, recent *session) picker.Target {
487+ return picker.Target{Session: recent.id, TabIndex: 0}
488+ },
489+ wantCurrent: "recent",
490+ wantPrev: "current",
491+ },
492+ {
493+ name: "same-session tab switch does not record",
494+ target: func(current *session, _ *session) picker.Target {
495+ return picker.Target{Session: current.id, TabIndex: 0}
496+ },
497+ wantCurrent: "current",
498+ wantPrev: "",
499+ },
500+ {
501+ name: "missing target does not record",
502+ target: func(_ *session, _ *session) picker.Target {
503+ return picker.Target{Session: "missing", TabIndex: -1}
504+ },
505+ wantCurrent: "current",
506+ wantPrev: "",
507+ },
508+ } {
509+ t.Run(tc.name, func(t *testing.T) {
510+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
511+ defer releaseAll(releases)
512+ recent := d.sessions[domain.SessionID("recent")]
513+
514+ d.switchToTarget(current, ac, tc.target(current, recent))
515+ require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
516+ if tc.wantPrev == "" {
517+ require.Nil(t, ac.previousSession.Get())
518+ return
659 }519 }
660- default:520+ require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id)
661- return count521+ })
662- }
663 }522 }
664 }523 }
665 524
666-type historyNavTestClock struct {525+func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
667- timers chan *historyNavTestTimer526+ for _, tc := range []struct {
668-}527+ name string
669-528+ teardown func(*Daemon, *session, *attachedClient)
670-func (c *historyNavTestClock) Now() time.Time { return time.Time{} }529+ }{
671-530+ {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }},
672-func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {531+ {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
673- t := &historyNavTestTimer{ch: make(chan time.Time, 1)}532+ {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) {
674- c.timers <- t533+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
675- return t534+ }},
676-}535+ } {
536+ t.Run(tc.name, func(t *testing.T) {
537+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
538+ defer releaseAll(releases)
677 539
678-type historyNavTestTimer struct {540+ ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
679- ch chan time.Time541+ tc.teardown(d, current, ac)
680-}
681 542
682-func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }543+ require.Nil(t, ac.previousSession.Get())
683-func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }544+ })
684-func (t *historyNavTestTimer) Stop() bool { return true }545+ }
685-func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
686-
687-func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
688- t.Run("forward skips a killed intermediate session", func(t *testing.T) {
689- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
690- defer releaseAll(releases)
691- recent := d.sessions[domain.SessionID("recent")]
692- older := d.sessions[domain.SessionID("older")]
693-
694- runPaletteCommand(t, d, current, ac, "BSK")
695- runPaletteCommand(t, d, recent, ac, "BSK")
696- require.Same(t, older, ac.currentSession())
697-
698- d.mu.Lock()
699- delete(d.sessions, recent.id)
700- d.mu.Unlock()
701-
702- runPaletteCommand(t, d, older, ac, "FSK")
703- require.Same(t, current, ac.currentSession())
704- })
705-
706- t.Run("back from current refreshes newly recent sessions", func(t *testing.T) {
707- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
708- defer releaseAll(releases)
709- p, release := newBlockingPTY(t)
710- defer release()
711- newer := &session{id: "newer", name: "newer", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p, domain.Size{Cols: 80, Rows: 23})}}
712- newer.mruAt.Store(25)
713- d.mu.Lock()
714- d.sessions[newer.id] = newer
715- d.mu.Unlock()
716-
717- runPaletteCommand(t, d, current, ac, "BSK")
718- require.Same(t, newer, ac.currentSession())
719- })
720-
721- t.Run("failed switch does not advance navigator index", func(t *testing.T) {
722- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
723- defer releaseAll(releases)
724- current.mu.Lock()
725- current.client = nil
726- current.mu.Unlock()
727-
728- runPaletteCommand(t, d, current, ac, "BSK")
729-
730- require.Same(t, current, ac.currentSession())
731- require.Zero(t, ac.recentNav.index)
732- })
733 }546 }
734 547
735 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {548 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
M internal/usecase/daemon/overlay_runtime.go +14 -3
@@ -19,9 +19,12 @@ type overlayRuntime struct {
1919 pickerPending []byte
2020 pickerESC pendingByteTimer
2121
22- paletteMu sync.Mutex
23- palette *palette.Model
24- 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+ palettePending []byte
2528
2629 promptMu sync.Mutex
2730 prompt *promptui.Model
@@ -164,6 +167,10 @@ type overlayRenderSnapshot struct {
164167
165168 paletteActive bool
166169 paletteModel *palette.Model
170+ // paletteHints is a copy captured under paletteMu. Rendering must use this
171+ // immutable interaction snapshot rather than consult live session state.
172+ paletteHints *palette.ContextualHints
173+ paletteRecent []recentSession
167174 paletteLocked bool
168175
169176 promptActive bool
@@ -209,6 +216,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
209216 snap.paletteModel = rt.palette
210217 snap.paletteActive = snap.paletteModel != nil
211218 if snap.paletteActive {
219+ hints := rt.paletteHints
220+ hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
221+ snap.paletteHints = &hints
222+ snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
212223 snap.paletteLocked = true
213224 } else {
214225 rt.paletteMu.Unlock()
@@ -19,9 +19,12 @@ 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- palettePending []byte24+ paletteRecent []recentSession // immutable for this palette interaction
25+ paletteGeneration uint64
26+ paletteHints palette.ContextualHints
27+ palettePending []byte
25 28
26 promptMu sync.Mutex29 promptMu sync.Mutex
27 prompt *promptui.Model30 prompt *promptui.Model
@@ -164,6 +167,10 @@ type overlayRenderSnapshot struct {
164 167
165 paletteActive bool168 paletteActive bool
166 paletteModel *palette.Model169 paletteModel *palette.Model
170+ // paletteHints is a copy captured under paletteMu. Rendering must use this
171+ // immutable interaction snapshot rather than consult live session state.
172+ paletteHints *palette.ContextualHints
173+ paletteRecent []recentSession
167 paletteLocked bool174 paletteLocked bool
168 175
169 promptActive bool176 promptActive bool
@@ -209,6 +216,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
209 snap.paletteModel = rt.palette216 snap.paletteModel = rt.palette
210 snap.paletteActive = snap.paletteModel != nil217 snap.paletteActive = snap.paletteModel != nil
211 if snap.paletteActive {218 if snap.paletteActive {
219+ hints := rt.paletteHints
220+ hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
221+ snap.paletteHints = &hints
222+ snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
212 snap.paletteLocked = true223 snap.paletteLocked = true
213 } else {224 } else {
214 rt.paletteMu.Unlock()225 rt.paletteMu.Unlock()
M internal/usecase/daemon/palette.go +154 -53
@@ -5,6 +5,7 @@ import (
55 "github.com/bnema/vev/internal/usecase/command"
66 "github.com/bnema/vev/internal/usecase/layout"
77 "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
89 "github.com/bnema/vev/internal/usecase/ui"
910 "github.com/bnema/vev/pkg/renderer"
1011 )
@@ -30,19 +31,26 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3031 }
3132
3233 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
33- 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()
3438 ac.overlays.paletteMu.Lock()
35- ac.overlays.palette = palette.New(d.paletteCommands())
39+ ac.overlays.paletteGeneration++
40+ ac.overlays.palette = palette.New(commands)
41+ ac.overlays.paletteRecent = recent
42+ ac.overlays.paletteHints = palette.ContextualHints{}
3643 ac.overlays.palettePending = nil
3744 ac.overlays.paletteMu.Unlock()
3845 d.paint(sess, ac, true)
3946 }
4047
41-func (d *Daemon) closePalette(ac *attachedClient) {
42- ac.overlays.paletteMu.Lock()
43- ac.overlays.palette = nil
44- ac.overlays.palettePending = nil
45- ac.overlays.paletteMu.Unlock()
48+func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
49+ names := make([]string, len(recent))
50+ for i, entry := range recent {
51+ names[i] = entry.name
52+ }
53+ return palette.BuildRecentSessionHints(names, args)
4654 }
4755
4856 func (d *Daemon) paletteCommands() []command.Command {
@@ -95,6 +103,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
95103 if sess == nil {
96104 return
97105 }
106+ var cmd command.Command
107+ var args []string
108+ var recent []recentSession
109+ var generation uint64
110+ var rawQuery string
111+ changed, cancel, execute := false, false, false
98112
99113 ac.overlays.paletteMu.Lock()
100114 if ac.overlays.palette == nil {
@@ -102,12 +116,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
102116 ac.overlays.paletteMu.Unlock()
103117 return
104118 }
105- changed := false
106- exit := false
107- run := false
108- var cmd command.Command
109- var ok bool
110-
111119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
112120 rune: func(r rune) {
113121 ac.overlays.palette.Insert(r)
@@ -117,56 +125,137 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
117125 ac.overlays.palette.Backspace()
118126 changed = true
119127 },
128+ up: func() { ac.overlays.palette.Up(); changed = true },
129+ down: func() { ac.overlays.palette.Down(); changed = true },
130+ cancel: func() { cancel = true },
120131 enter: func() {
121- cmd, ok = ac.overlays.palette.Selected()
122- if ok {
123- run = true
124- exit = true
132+ var selected bool
133+ cmd, selected = ac.overlays.palette.Selected()
134+ if !selected {
135+ changed = true
136+ return
125137 }
126- },
127- cancel: func() { exit = true },
128- up: func() {
129- ac.overlays.palette.Up()
130- changed = true
131- },
132- down: func() {
133- ac.overlays.palette.Down()
134- changed = true
138+ rawQuery = ac.overlays.palette.Query()
139+ // JRS is contextual: it is executable only from its exact token so
140+ // fuzzy selection can never turn an unrelated query into a jump.
141+ // Static commands retain normal palette behavior and run the selected
142+ // fuzzy match.
143+ if cmd.Slug == "jump-recent-session" {
144+ action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
145+ if !valid {
146+ changed = true
147+ return
148+ }
149+ args = action.Args
150+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
151+ }
152+ generation = ac.overlays.paletteGeneration
153+ execute = true
135154 },
136155 })
137- if exit {
138- ac.overlays.palette = nil
139- ac.overlays.palettePending = nil
156+ if changed {
157+ active, ok := ac.overlays.palette.ArgumentCommand()
158+ if ok && active.Slug == "jump-recent-session" {
159+ hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
160+ ac.overlays.paletteHints = hints
161+ } else {
162+ ac.overlays.paletteHints = palette.ContextualHints{}
163+ }
164+ }
165+ if cancel {
166+ ac.clearPaletteLocked()
140167 }
141168 ac.overlays.paletteMu.Unlock()
169+ if cancel {
170+ d.paint(sess, ac, true)
171+ return
172+ }
173+ if !execute {
174+ if changed {
175+ d.paint(sess, ac, true)
176+ }
177+ return
178+ }
142179
143- if run && ok {
144- if !isHistoryNavigationCommand(cmd.Slug) {
145- d.clearHistoryNav(ac)
180+ // Revalidate live target without paletteMu. A captured ID is never replaced
181+ // by a newly-ranked session, so MRU changes cannot shift a requested rank.
182+ if cmd.Slug == "jump-recent-session" {
183+ rank, err := command.ParsePositiveDecimal(args)
184+ if err != nil {
185+ ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186+ d.paint(sess, ac, true)
187+ return
146188 }
147- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
148- sess.mu.Lock()
149- name := sess.name
150- sess.mu.Unlock()
151- d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)
152- } else {
189+ // The captured ID is handed off atomically. A target can disappear after
190+ // the validation above, so closing is contingent on the committed switch.
191+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192+ if err := exec.JumpRecentSession(rank); err != nil {
193+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194+ d.paint(sess, ac, true)
195+ return
196+ }
197+ // Publication remains generation-safe; do not let stale work close a
198+ // newer palette interaction.
199+ if ac.closeExecutedPalette(generation, rawQuery) {
153200 d.recordPaletteUse(cmd.Code)
201+ d.paint(ac.currentSession(), ac, true)
154202 }
155203 return
156204 }
157- if exit || changed {
158- d.paint(sess, ac, true)
205+ if !ac.closeExecutedPalette(generation, rawQuery) {
206+ return
159207 }
208+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
209+ d.log.Error("palette command failed", "err", err, "command", cmd.Code)
210+ } else {
211+ d.recordPaletteUse(cmd.Code)
212+ }
213+}
214+
215+func paletteArgs(query string, cmd command.Command) []string {
216+ action, ok := palette.ParseAction([]command.Command{cmd}, query)
217+ if !ok {
218+ return nil
219+ }
220+ return action.Args
221+}
222+
223+func (ac *attachedClient) clearPaletteLocked() {
224+ ac.overlays.paletteGeneration++
225+ ac.overlays.palette = nil
226+ ac.overlays.paletteRecent = nil
227+ ac.overlays.paletteHints = palette.ContextualHints{}
228+ ac.overlays.palettePending = nil
160229 }
161230
162-func isHistoryNavigationCommand(slug string) bool {
163- return slug == "back-session" || slug == "forward-session"
231+func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
232+ ac.overlays.paletteMu.Lock()
233+ defer ac.overlays.paletteMu.Unlock()
234+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
235+ return
236+ }
237+ // Rendering consumes the immutable contextual hint snapshot; preserve ranks
238+ // while making stale-target feedback visible.
239+ if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
240+ ac.overlays.paletteHints.Feedback = feedback
241+ }
242+}
243+
244+func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
245+ ac.overlays.paletteMu.Lock()
246+ defer ac.overlays.paletteMu.Unlock()
247+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
248+ return false
249+ }
250+ ac.clearPaletteLocked()
251+ return true
164252 }
165253
166254 type paletteExec struct {
167- d *Daemon
168- sess *session
169- ac *attachedClient
255+ d *Daemon
256+ sess *session
257+ ac *attachedClient
258+ recent []recentSession
170259 }
171260
172261 func (e paletteExec) CreateTab() error {
@@ -253,12 +342,7 @@ func (e paletteExec) ToggleFloatingPane() error {
253342 }
254343
255344 func (e paletteExec) BackSession() error {
256- e.d.navigateRecentSession(e.sess, e.ac, 1)
257- return nil
258-}
259-
260-func (e paletteExec) ForwardSession() error {
261- e.d.navigateRecentSession(e.sess, e.ac, -1)
345+ e.d.backSession(e.sess, e.ac)
262346 return nil
263347 }
264348
@@ -301,10 +385,27 @@ func (e paletteExec) OpenSessionPicker() error {
301385 return nil
302386 }
303387
304-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
388+func (e paletteExec) JumpRecentSession(rank int) error {
389+ if rank < 1 || rank > len(e.recent) {
390+ return command.ErrInvalidArguments
391+ }
392+ target := e.recent[rank-1]
393+ if e.d.sessionByID(target.id) == nil {
394+ return command.ErrInvalidArguments
395+ }
396+ if e.d.beforeRecentSessionHandoff != nil {
397+ e.d.beforeRecentSessionHandoff()
398+ }
399+ if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) {
400+ return command.ErrInvalidArguments
401+ }
402+ return nil
403+}
404+
405+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
305406 styleSet := resolveThemeStyles(styles)
306407 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
307408 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
308- return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})
409+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance})
309410 })
310411 }
@@ -5,6 +5,7 @@ import (
5 "github.com/bnema/vev/internal/usecase/command"5 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/layout"6 "github.com/bnema/vev/internal/usecase/layout"
7 "github.com/bnema/vev/internal/usecase/palette"7 "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
8 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 )11 )
@@ -30,19 +31,26 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
30 }31 }
31 32
32 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {33 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
33- 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()
34 ac.overlays.paletteMu.Lock()38 ac.overlays.paletteMu.Lock()
35- ac.overlays.palette = palette.New(d.paletteCommands())39+ ac.overlays.paletteGeneration++
40+ ac.overlays.palette = palette.New(commands)
41+ ac.overlays.paletteRecent = recent
42+ ac.overlays.paletteHints = palette.ContextualHints{}
36 ac.overlays.palettePending = nil43 ac.overlays.palettePending = nil
37 ac.overlays.paletteMu.Unlock()44 ac.overlays.paletteMu.Unlock()
38 d.paint(sess, ac, true)45 d.paint(sess, ac, true)
39 }46 }
40 47
41-func (d *Daemon) closePalette(ac *attachedClient) {48+func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
42- ac.overlays.paletteMu.Lock()49+ names := make([]string, len(recent))
43- ac.overlays.palette = nil50+ for i, entry := range recent {
44- ac.overlays.palettePending = nil51+ names[i] = entry.name
45- ac.overlays.paletteMu.Unlock()52+ }
53+ return palette.BuildRecentSessionHints(names, args)
46 }54 }
47 55
48 func (d *Daemon) paletteCommands() []command.Command {56 func (d *Daemon) paletteCommands() []command.Command {
@@ -95,6 +103,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
95 if sess == nil {103 if sess == nil {
96 return104 return
97 }105 }
106+ var cmd command.Command
107+ var args []string
108+ var recent []recentSession
109+ var generation uint64
110+ var rawQuery string
111+ changed, cancel, execute := false, false, false
98 112
99 ac.overlays.paletteMu.Lock()113 ac.overlays.paletteMu.Lock()
100 if ac.overlays.palette == nil {114 if ac.overlays.palette == nil {
@@ -102,12 +116,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
102 ac.overlays.paletteMu.Unlock()116 ac.overlays.paletteMu.Unlock()
103 return117 return
104 }118 }
105- changed := false
106- exit := false
107- run := false
108- var cmd command.Command
109- var ok bool
110-
111 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
112 rune: func(r rune) {120 rune: func(r rune) {
113 ac.overlays.palette.Insert(r)121 ac.overlays.palette.Insert(r)
@@ -117,56 +125,137 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
117 ac.overlays.palette.Backspace()125 ac.overlays.palette.Backspace()
118 changed = true126 changed = true
119 },127 },
128+ up: func() { ac.overlays.palette.Up(); changed = true },
129+ down: func() { ac.overlays.palette.Down(); changed = true },
130+ cancel: func() { cancel = true },
120 enter: func() {131 enter: func() {
121- cmd, ok = ac.overlays.palette.Selected()132+ var selected bool
122- if ok {133+ cmd, selected = ac.overlays.palette.Selected()
123- run = true134+ if !selected {
124- exit = true135+ changed = true
136+ return
125 }137 }
126- },138+ rawQuery = ac.overlays.palette.Query()
127- cancel: func() { exit = true },139+ // JRS is contextual: it is executable only from its exact token so
128- up: func() {140+ // fuzzy selection can never turn an unrelated query into a jump.
129- ac.overlays.palette.Up()141+ // Static commands retain normal palette behavior and run the selected
130- changed = true142+ // fuzzy match.
131- },143+ if cmd.Slug == "jump-recent-session" {
132- down: func() {144+ action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
133- ac.overlays.palette.Down()145+ if !valid {
134- changed = true146+ changed = true
147+ return
148+ }
149+ args = action.Args
150+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
151+ }
152+ generation = ac.overlays.paletteGeneration
153+ execute = true
135 },154 },
136 })155 })
137- if exit {156+ if changed {
138- ac.overlays.palette = nil157+ active, ok := ac.overlays.palette.ArgumentCommand()
139- ac.overlays.palettePending = nil158+ if ok && active.Slug == "jump-recent-session" {
159+ hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
160+ ac.overlays.paletteHints = hints
161+ } else {
162+ ac.overlays.paletteHints = palette.ContextualHints{}
163+ }
164+ }
165+ if cancel {
166+ ac.clearPaletteLocked()
140 }167 }
141 ac.overlays.paletteMu.Unlock()168 ac.overlays.paletteMu.Unlock()
169+ if cancel {
170+ d.paint(sess, ac, true)
171+ return
172+ }
173+ if !execute {
174+ if changed {
175+ d.paint(sess, ac, true)
176+ }
177+ return
178+ }
142 179
143- if run && ok {180+ // Revalidate live target without paletteMu. A captured ID is never replaced
144- if !isHistoryNavigationCommand(cmd.Slug) {181+ // by a newly-ranked session, so MRU changes cannot shift a requested rank.
145- d.clearHistoryNav(ac)182+ if cmd.Slug == "jump-recent-session" {
183+ rank, err := command.ParsePositiveDecimal(args)
184+ if err != nil {
185+ ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186+ d.paint(sess, ac, true)
187+ return
146 }188 }
147- if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {189+ // The captured ID is handed off atomically. A target can disappear after
148- sess.mu.Lock()190+ // the validation above, so closing is contingent on the committed switch.
149- name := sess.name191+ exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
150- sess.mu.Unlock()192+ if err := exec.JumpRecentSession(rank); err != nil {
151- d.log.Error("palette command failed", "err", err, "session", name, "command", cmd.Code)193+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
152- } else {194+ d.paint(sess, ac, true)
195+ return
196+ }
197+ // Publication remains generation-safe; do not let stale work close a
198+ // newer palette interaction.
199+ if ac.closeExecutedPalette(generation, rawQuery) {
153 d.recordPaletteUse(cmd.Code)200 d.recordPaletteUse(cmd.Code)
201+ d.paint(ac.currentSession(), ac, true)
154 }202 }
155 return203 return
156 }204 }
157- if exit || changed {205+ if !ac.closeExecutedPalette(generation, rawQuery) {
158- d.paint(sess, ac, true)206+ return
159 }207 }
208+ if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args); err != nil {
209+ d.log.Error("palette command failed", "err", err, "command", cmd.Code)
210+ } else {
211+ d.recordPaletteUse(cmd.Code)
212+ }
213+}
214+
215+func paletteArgs(query string, cmd command.Command) []string {
216+ action, ok := palette.ParseAction([]command.Command{cmd}, query)
217+ if !ok {
218+ return nil
219+ }
220+ return action.Args
221+}
222+
223+func (ac *attachedClient) clearPaletteLocked() {
224+ ac.overlays.paletteGeneration++
225+ ac.overlays.palette = nil
226+ ac.overlays.paletteRecent = nil
227+ ac.overlays.paletteHints = palette.ContextualHints{}
228+ ac.overlays.palettePending = nil
160 }229 }
161 230
162-func isHistoryNavigationCommand(slug string) bool {231+func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
163- return slug == "back-session" || slug == "forward-session"232+ ac.overlays.paletteMu.Lock()
233+ defer ac.overlays.paletteMu.Unlock()
234+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
235+ return
236+ }
237+ // Rendering consumes the immutable contextual hint snapshot; preserve ranks
238+ // while making stale-target feedback visible.
239+ if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
240+ ac.overlays.paletteHints.Feedback = feedback
241+ }
242+}
243+
244+func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
245+ ac.overlays.paletteMu.Lock()
246+ defer ac.overlays.paletteMu.Unlock()
247+ if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
248+ return false
249+ }
250+ ac.clearPaletteLocked()
251+ return true
164 }252 }
165 253
166 type paletteExec struct {254 type paletteExec struct {
167- d *Daemon255+ d *Daemon
168- sess *session256+ sess *session
169- ac *attachedClient257+ ac *attachedClient
258+ recent []recentSession
170 }259 }
171 260
172 func (e paletteExec) CreateTab() error {261 func (e paletteExec) CreateTab() error {
@@ -253,12 +342,7 @@ func (e paletteExec) ToggleFloatingPane() error {
253 }342 }
254 343
255 func (e paletteExec) BackSession() error {344 func (e paletteExec) BackSession() error {
256- e.d.navigateRecentSession(e.sess, e.ac, 1)345+ e.d.backSession(e.sess, e.ac)
257- return nil
258-}
259-
260-func (e paletteExec) ForwardSession() error {
261- e.d.navigateRecentSession(e.sess, e.ac, -1)
262 return nil346 return nil
263 }347 }
264 348
@@ -301,10 +385,27 @@ func (e paletteExec) OpenSessionPicker() error {
301 return nil385 return nil
302 }386 }
303 387
304-func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {388+func (e paletteExec) JumpRecentSession(rank int) error {
389+ if rank < 1 || rank > len(e.recent) {
390+ return command.ErrInvalidArguments
391+ }
392+ target := e.recent[rank-1]
393+ if e.d.sessionByID(target.id) == nil {
394+ return command.ErrInvalidArguments
395+ }
396+ if e.d.beforeRecentSessionHandoff != nil {
397+ e.d.beforeRecentSessionHandoff()
398+ }
399+ if !e.d.switchToTarget(e.sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) {
400+ return command.ErrInvalidArguments
401+ }
402+ return nil
403+}
404+
405+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
305 styleSet := resolveThemeStyles(styles)406 styleSet := resolveThemeStyles(styles)
306 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)407 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
307 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {408 return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
308- return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc})409+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance})
309 })410 })
310 }411 }
M internal/usecase/daemon/palette_test.go +186 -4
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/command"
1415 "github.com/bnema/vev/internal/usecase/palette"
1516 "github.com/bnema/vev/internal/usecase/ui"
1617 "github.com/bnema/vev/pkg/renderer"
@@ -47,6 +48,187 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
4748 awaitFrame(t, sends, ports.MsgOutput)
4849 }
4950
51+func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52+ p, release := newBlockingPTY(t)
53+ defer release()
54+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
55+
56+ d.handleInput(sess, ac, []byte("\x1b "))
57+ awaitFrame(t, sends, ports.MsgOutput)
58+ d.handleInput(sess, ac, []byte("JRS"))
59+ awaitFrame(t, sends, ports.MsgOutput)
60+
61+ ac.overlays.paletteMu.Lock()
62+ require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
63+ require.Equal(t, "no recent sessions", ac.overlays.paletteHints.Feedback)
64+ ac.overlays.paletteMu.Unlock()
65+
66+ d.handleInput(sess, ac, []byte("\b\b\bRNS"))
67+ awaitFrame(t, sends, ports.MsgOutput)
68+ ac.overlays.paletteMu.Lock()
69+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
70+ ac.overlays.paletteMu.Unlock()
71+}
72+
73+func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
74+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
75+ defer releaseAll(releases)
76+ d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
77+
78+ d.handleInput(current, ac, []byte("\x1b "))
79+ awaitFrame(t, sends, ports.MsgOutput)
80+ d.handleInput(current, ac, []byte("RJS"))
81+ awaitFrame(t, sends, ports.MsgOutput)
82+ ac.overlays.paletteMu.Lock()
83+ require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
84+ ac.overlays.paletteMu.Unlock()
85+ d.handleInput(current, ac, []byte(" 1\r"))
86+
87+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
88+ require.False(t, ac.overlays.paletteActive())
89+ awaitFrame(t, sends, ports.MsgOutput)
90+ awaitFrame(t, sends, ports.MsgOutput)
91+
92+ d.handleInput(ac.currentSession(), ac, []byte("\x1b "))
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ d.handleInput(ac.currentSession(), ac, []byte("JRS"))
95+ awaitFrame(t, sends, ports.MsgOutput)
96+ ac.overlays.paletteMu.Lock()
97+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
98+ ac.overlays.paletteMu.Unlock()
99+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
100+ d.handleInput(ac.currentSession(), ac, []byte("\r"))
101+ require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
102+}
103+
104+func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
105+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
106+ defer releases[0]()
107+ defer releases[1]()
108+
109+ d.handleInput(sess, ac, []byte("\x1b "))
110+ awaitFrame(t, sends, ports.MsgOutput)
111+ d.handleInput(sess, ac, []byte("next\r"))
112+
113+ require.False(t, ac.overlays.paletteActive())
114+ require.Equal(t, 1, activeTabIndex(sess))
115+ awaitFrame(t, sends, ports.MsgOutput)
116+}
117+
118+func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
119+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
120+ defer releaseAll(releases)
121+ captured := d.sessions[domain.SessionID("recent")]
122+ d.handleInput(current, ac, []byte("\x1b "))
123+ awaitFrame(t, sends, ports.MsgOutput)
124+ // Reordering live MRU after opening must not shift rank 1 from its capture.
125+ d.sessions[domain.SessionID("older")].mruAt.Store(100)
126+ d.handleInput(current, ac, []byte("JRS 1\r"))
127+
128+ require.Same(t, captured, ac.currentSession())
129+ require.False(t, ac.overlays.paletteActive())
130+ awaitFrame(t, sends, ports.MsgOutput)
131+ awaitFrame(t, sends, ports.MsgOutput)
132+}
133+
134+func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
135+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
136+ defer releaseAll(releases)
137+
138+ d.handleInput(current, ac, []byte("\x1b "))
139+ awaitFrame(t, sends, ports.MsgOutput)
140+ d.handleInput(current, ac, []byte("JRS 1\r"))
141+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
142+ awaitFrame(t, sends, ports.MsgOutput)
143+ awaitFrame(t, sends, ports.MsgOutput)
144+
145+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
146+ require.Same(t, current, ac.currentSession())
147+}
148+
149+func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
150+ p, release := newBlockingPTY(t)
151+ defer release()
152+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
153+ target := &session{id: "captured", name: "captured"}
154+ d.sessions[target.id] = target
155+
156+ validated := make(chan struct{})
157+ releaseHandoff := make(chan struct{})
158+ d.beforeRecentSessionHandoff = func() {
159+ close(validated)
160+ <-releaseHandoff
161+ }
162+ d.handleInput(sess, ac, []byte("\x1b "))
163+ awaitFrame(t, sends, ports.MsgOutput)
164+ go d.handleInput(sess, ac, []byte("JRS 1\r"))
165+ <-validated
166+ d.mu.Lock()
167+ delete(d.sessions, target.id)
168+ d.mu.Unlock()
169+ close(releaseHandoff)
170+
171+ awaitFrame(t, sends, ports.MsgOutput)
172+ require.True(t, ac.overlays.paletteActive())
173+ ac.overlays.paletteMu.Lock()
174+ require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
176+ ac.overlays.paletteMu.Unlock()
177+}
178+
179+func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
180+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
181+ defer releaseAll(releases)
182+
183+ d.handleInput(current, ac, []byte("\x1b "))
184+ awaitFrame(t, sends, ports.MsgOutput)
185+ d.handleInput(current, ac, []byte("JRS 3\r"))
186+
187+ require.Same(t, current, ac.currentSession())
188+ require.True(t, ac.overlays.paletteActive())
189+ ac.overlays.paletteMu.Lock()
190+ require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
192+ ac.overlays.paletteMu.Unlock()
193+ awaitFrame(t, sends, ports.MsgOutput)
194+}
195+
196+func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
197+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
198+ defer releaseAll(releases)
199+
200+ d.handleInput(current, ac, []byte("\x1b "))
201+ awaitFrame(t, sends, ports.MsgOutput)
202+ d.handleInput(current, ac, []byte("JRS 0\r"))
203+
204+ require.Same(t, current, ac.currentSession())
205+ require.True(t, ac.overlays.paletteActive())
206+ ac.overlays.paletteMu.Lock()
207+ require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
209+ ac.overlays.paletteMu.Unlock()
210+ awaitFrame(t, sends, ports.MsgOutput)
211+}
212+
213+func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
214+ p, release := newBlockingPTY(t)
215+ defer release()
216+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
217+
218+ d.enterPalette(sess, ac)
219+ ac.overlays.paletteMu.Lock()
220+ staleGeneration := ac.overlays.paletteGeneration
221+ ac.overlays.paletteMu.Unlock()
222+ d.enterPalette(sess, ac)
223+ ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224+
225+ ac.overlays.paletteMu.Lock()
226+ require.Empty(t, ac.overlays.paletteHints.Feedback)
227+ ac.overlays.paletteMu.Unlock()
228+ awaitFrame(t, sends, ports.MsgOutput)
229+ awaitFrame(t, sends, ports.MsgOutput)
230+}
231+
50232 func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
51233 p, release := newBlockingPTY(t)
52234 defer release()
@@ -138,12 +320,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138320 for i, cmd := range commands {
139321 codes[i] = cmd.Code
140322 }
141- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
323+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes)
142324 }
143325
144326 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
145327 d := &Daemon{}
146- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
328+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
147329
148330 var wg sync.WaitGroup
149331 for range 50 {
@@ -159,7 +341,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
159341 wg.Wait()
160342
161343 commands := d.paletteCommands()
162- require.Len(t, commands, len(codes))
344+ require.Len(t, commands, len(command.Registry()))
163345 seen := map[string]bool{}
164346 for _, cmd := range commands {
165347 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
@@ -289,7 +471,7 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
289471 {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
290472 } {
291473 t.Run(tt.name, func(t *testing.T) {
292- frame, _ := composePaletteClientFrame(model, base, tt.cfg)
474+ frame, _ := composePaletteClientFrame(model, base, tt.cfg, "")
293475 require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
294476 })
295477 }
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/palette"15 "github.com/bnema/vev/internal/usecase/palette"
15 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
16 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
@@ -47,6 +48,187 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
47 awaitFrame(t, sends, ports.MsgOutput)48 awaitFrame(t, sends, ports.MsgOutput)
48 }49 }
49 50
51+func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52+ p, release := newBlockingPTY(t)
53+ defer release()
54+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
55+
56+ d.handleInput(sess, ac, []byte("\x1b "))
57+ awaitFrame(t, sends, ports.MsgOutput)
58+ d.handleInput(sess, ac, []byte("JRS"))
59+ awaitFrame(t, sends, ports.MsgOutput)
60+
61+ ac.overlays.paletteMu.Lock()
62+ require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
63+ require.Equal(t, "no recent sessions", ac.overlays.paletteHints.Feedback)
64+ ac.overlays.paletteMu.Unlock()
65+
66+ d.handleInput(sess, ac, []byte("\b\b\bRNS"))
67+ awaitFrame(t, sends, ports.MsgOutput)
68+ ac.overlays.paletteMu.Lock()
69+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
70+ ac.overlays.paletteMu.Unlock()
71+}
72+
73+func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
74+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
75+ defer releaseAll(releases)
76+ d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
77+
78+ d.handleInput(current, ac, []byte("\x1b "))
79+ awaitFrame(t, sends, ports.MsgOutput)
80+ d.handleInput(current, ac, []byte("RJS"))
81+ awaitFrame(t, sends, ports.MsgOutput)
82+ ac.overlays.paletteMu.Lock()
83+ require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
84+ ac.overlays.paletteMu.Unlock()
85+ d.handleInput(current, ac, []byte(" 1\r"))
86+
87+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
88+ require.False(t, ac.overlays.paletteActive())
89+ awaitFrame(t, sends, ports.MsgOutput)
90+ awaitFrame(t, sends, ports.MsgOutput)
91+
92+ d.handleInput(ac.currentSession(), ac, []byte("\x1b "))
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ d.handleInput(ac.currentSession(), ac, []byte("JRS"))
95+ awaitFrame(t, sends, ports.MsgOutput)
96+ ac.overlays.paletteMu.Lock()
97+ require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
98+ ac.overlays.paletteMu.Unlock()
99+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
100+ d.handleInput(ac.currentSession(), ac, []byte("\r"))
101+ require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
102+}
103+
104+func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
105+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
106+ defer releases[0]()
107+ defer releases[1]()
108+
109+ d.handleInput(sess, ac, []byte("\x1b "))
110+ awaitFrame(t, sends, ports.MsgOutput)
111+ d.handleInput(sess, ac, []byte("next\r"))
112+
113+ require.False(t, ac.overlays.paletteActive())
114+ require.Equal(t, 1, activeTabIndex(sess))
115+ awaitFrame(t, sends, ports.MsgOutput)
116+}
117+
118+func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
119+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
120+ defer releaseAll(releases)
121+ captured := d.sessions[domain.SessionID("recent")]
122+ d.handleInput(current, ac, []byte("\x1b "))
123+ awaitFrame(t, sends, ports.MsgOutput)
124+ // Reordering live MRU after opening must not shift rank 1 from its capture.
125+ d.sessions[domain.SessionID("older")].mruAt.Store(100)
126+ d.handleInput(current, ac, []byte("JRS 1\r"))
127+
128+ require.Same(t, captured, ac.currentSession())
129+ require.False(t, ac.overlays.paletteActive())
130+ awaitFrame(t, sends, ports.MsgOutput)
131+ awaitFrame(t, sends, ports.MsgOutput)
132+}
133+
134+func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
135+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
136+ defer releaseAll(releases)
137+
138+ d.handleInput(current, ac, []byte("\x1b "))
139+ awaitFrame(t, sends, ports.MsgOutput)
140+ d.handleInput(current, ac, []byte("JRS 1\r"))
141+ require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
142+ awaitFrame(t, sends, ports.MsgOutput)
143+ awaitFrame(t, sends, ports.MsgOutput)
144+
145+ runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
146+ require.Same(t, current, ac.currentSession())
147+}
148+
149+func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
150+ p, release := newBlockingPTY(t)
151+ defer release()
152+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
153+ target := &session{id: "captured", name: "captured"}
154+ d.sessions[target.id] = target
155+
156+ validated := make(chan struct{})
157+ releaseHandoff := make(chan struct{})
158+ d.beforeRecentSessionHandoff = func() {
159+ close(validated)
160+ <-releaseHandoff
161+ }
162+ d.handleInput(sess, ac, []byte("\x1b "))
163+ awaitFrame(t, sends, ports.MsgOutput)
164+ go d.handleInput(sess, ac, []byte("JRS 1\r"))
165+ <-validated
166+ d.mu.Lock()
167+ delete(d.sessions, target.id)
168+ d.mu.Unlock()
169+ close(releaseHandoff)
170+
171+ awaitFrame(t, sends, ports.MsgOutput)
172+ require.True(t, ac.overlays.paletteActive())
173+ ac.overlays.paletteMu.Lock()
174+ require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
176+ ac.overlays.paletteMu.Unlock()
177+}
178+
179+func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
180+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
181+ defer releaseAll(releases)
182+
183+ d.handleInput(current, ac, []byte("\x1b "))
184+ awaitFrame(t, sends, ports.MsgOutput)
185+ d.handleInput(current, ac, []byte("JRS 3\r"))
186+
187+ require.Same(t, current, ac.currentSession())
188+ require.True(t, ac.overlays.paletteActive())
189+ ac.overlays.paletteMu.Lock()
190+ require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
192+ ac.overlays.paletteMu.Unlock()
193+ awaitFrame(t, sends, ports.MsgOutput)
194+}
195+
196+func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
197+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
198+ defer releaseAll(releases)
199+
200+ d.handleInput(current, ac, []byte("\x1b "))
201+ awaitFrame(t, sends, ports.MsgOutput)
202+ d.handleInput(current, ac, []byte("JRS 0\r"))
203+
204+ require.Same(t, current, ac.currentSession())
205+ require.True(t, ac.overlays.paletteActive())
206+ ac.overlays.paletteMu.Lock()
207+ require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
209+ ac.overlays.paletteMu.Unlock()
210+ awaitFrame(t, sends, ports.MsgOutput)
211+}
212+
213+func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
214+ p, release := newBlockingPTY(t)
215+ defer release()
216+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
217+
218+ d.enterPalette(sess, ac)
219+ ac.overlays.paletteMu.Lock()
220+ staleGeneration := ac.overlays.paletteGeneration
221+ ac.overlays.paletteMu.Unlock()
222+ d.enterPalette(sess, ac)
223+ ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224+
225+ ac.overlays.paletteMu.Lock()
226+ require.Empty(t, ac.overlays.paletteHints.Feedback)
227+ ac.overlays.paletteMu.Unlock()
228+ awaitFrame(t, sends, ports.MsgOutput)
229+ awaitFrame(t, sends, ports.MsgOutput)
230+}
231+
50 func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {232 func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
51 p, release := newBlockingPTY(t)233 p, release := newBlockingPTY(t)
52 defer release()234 defer release()
@@ -138,12 +320,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
138 for i, cmd := range commands {320 for i, cmd := range commands {
139 codes[i] = cmd.Code321 codes[i] = cmd.Code
140 }322 }
141- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)323+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "VIS", "RNS", "RNT", "DET"}, codes)
142 }324 }
143 325
144 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {326 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
145 d := &Daemon{}327 d := &Daemon{}
146- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}328+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"}
147 329
148 var wg sync.WaitGroup330 var wg sync.WaitGroup
149 for range 50 {331 for range 50 {
@@ -159,7 +341,7 @@ func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
159 wg.Wait()341 wg.Wait()
160 342
161 commands := d.paletteCommands()343 commands := d.paletteCommands()
162- require.Len(t, commands, len(codes))344+ require.Len(t, commands, len(command.Registry()))
163 seen := map[string]bool{}345 seen := map[string]bool{}
164 for _, cmd := range commands {346 for _, cmd := range commands {
165 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)347 require.False(t, seen[cmd.Code], "duplicate command %s", cmd.Code)
@@ -289,7 +471,7 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
289 {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},471 {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
290 } {472 } {
291 t.Run(tt.name, func(t *testing.T) {473 t.Run(tt.name, func(t *testing.T) {
292- frame, _ := composePaletteClientFrame(model, base, tt.cfg)474+ frame, _ := composePaletteClientFrame(model, base, tt.cfg, "")
293 require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)475 require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
294 })476 })
295 }477 }
M internal/usecase/daemon/picker.go +16 -11
@@ -356,35 +356,35 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
356356 return d.sessions[id]
357357 }
358358
359-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
360- d.clearHistoryNav(ac)
359+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {
361360 if target.Stopped {
362- d.resumeStoppedAndSwitch(sess, ac, target)
363- return
361+ return d.resumeStoppedAndSwitch(sess, ac, target)
364362 }
365363 targetSess := d.sessionByID(target.Session)
366364 if targetSess == nil {
367365 d.paint(sess, ac, true)
368- return
366+ return false
369367 }
370368 if targetSess == sess {
371369 if sess.switchTab(target.TabIndex) {
372370 d.activateTab(sess, sess.activeTab())
373371 }
374372 d.paint(sess, ac, true)
375- return
373+ return true
376374 }
377375 old := d.stealClientForTarget(sess, ac, targetSess, target)
378376 if ac.currentSession() != targetSess {
379377 d.paint(sess, ac, true)
380- return
378+ return false
381379 }
382380 if old != nil && old != ac {
383381 d.unregisterPreview(old)
382+ old.clearPreviousSession()
384383 old.setSession(nil)
385384 d.notifyDetachedAsync(old, ports.ReasonDetach)
386385 }
387386 d.firstPaint(targetSess, ac, ac.size)
387+ return true
388388 }
389389
390390 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
@@ -412,25 +412,27 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
412412 targetSess.active = target.TabIndex
413413 }
414414 targetSess.mu.Unlock()
415+
415416 d.touchMRU(targetSess)
416417 ac.setSession(targetSess)
418+ ac.recordPreviousSession(from)
417419 d.mu.Unlock()
418420 return old
419421 }
420422
421-func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {
423+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
422424 d.mu.Lock()
423425 stopped, ok := d.stopped[target.Name]
424426 if !ok || stopped.purging {
425427 d.mu.Unlock()
426428 d.paint(from, ac, true)
427- return
429+ return false
428430 }
429431 from.mu.Lock()
430432 if from.client != ac {
431433 from.mu.Unlock()
432434 d.mu.Unlock()
433- return
435+ return false
434436 }
435437 term := from.terminal
436438 cwd := d.dirOrHome(stopped.cwd)
@@ -440,7 +442,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
440442 d.mu.Unlock()
441443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
442444 d.paint(from, ac, true)
443- return
445+ return false
444446 }
445447 from.client = nil
446448 ac.setSession(nil)
@@ -448,10 +450,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
448450 targetSess.mu.Lock()
449451 targetSess.client = ac
450452 targetSess.mu.Unlock()
453+
451454 d.touchMRU(targetSess)
452455 ac.setSession(targetSess)
456+ ac.recordPreviousSession(from)
453457 d.mu.Unlock()
454458 d.firstPaint(targetSess, ac, ac.size)
459+ return true
455460 }
456461
457462 func (d *Daemon) killPickerTarget(target picker.Target) {
@@ -356,35 +356,35 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
356 return d.sessions[id]356 return d.sessions[id]
357 }357 }
358 358
359-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {359+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {
360- d.clearHistoryNav(ac)
361 if target.Stopped {360 if target.Stopped {
362- d.resumeStoppedAndSwitch(sess, ac, target)361+ return d.resumeStoppedAndSwitch(sess, ac, target)
363- return
364 }362 }
365 targetSess := d.sessionByID(target.Session)363 targetSess := d.sessionByID(target.Session)
366 if targetSess == nil {364 if targetSess == nil {
367 d.paint(sess, ac, true)365 d.paint(sess, ac, true)
368- return366+ return false
369 }367 }
370 if targetSess == sess {368 if targetSess == sess {
371 if sess.switchTab(target.TabIndex) {369 if sess.switchTab(target.TabIndex) {
372 d.activateTab(sess, sess.activeTab())370 d.activateTab(sess, sess.activeTab())
373 }371 }
374 d.paint(sess, ac, true)372 d.paint(sess, ac, true)
375- return373+ return true
376 }374 }
377 old := d.stealClientForTarget(sess, ac, targetSess, target)375 old := d.stealClientForTarget(sess, ac, targetSess, target)
378 if ac.currentSession() != targetSess {376 if ac.currentSession() != targetSess {
379 d.paint(sess, ac, true)377 d.paint(sess, ac, true)
380- return378+ return false
381 }379 }
382 if old != nil && old != ac {380 if old != nil && old != ac {
383 d.unregisterPreview(old)381 d.unregisterPreview(old)
382+ old.clearPreviousSession()
384 old.setSession(nil)383 old.setSession(nil)
385 d.notifyDetachedAsync(old, ports.ReasonDetach)384 d.notifyDetachedAsync(old, ports.ReasonDetach)
386 }385 }
387 d.firstPaint(targetSess, ac, ac.size)386 d.firstPaint(targetSess, ac, ac.size)
387+ return true
388 }388 }
389 389
390 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {390 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
@@ -412,25 +412,27 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
412 targetSess.active = target.TabIndex412 targetSess.active = target.TabIndex
413 }413 }
414 targetSess.mu.Unlock()414 targetSess.mu.Unlock()
415+
415 d.touchMRU(targetSess)416 d.touchMRU(targetSess)
416 ac.setSession(targetSess)417 ac.setSession(targetSess)
418+ ac.recordPreviousSession(from)
417 d.mu.Unlock()419 d.mu.Unlock()
418 return old420 return old
419 }421 }
420 422
421-func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {423+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
422 d.mu.Lock()424 d.mu.Lock()
423 stopped, ok := d.stopped[target.Name]425 stopped, ok := d.stopped[target.Name]
424 if !ok || stopped.purging {426 if !ok || stopped.purging {
425 d.mu.Unlock()427 d.mu.Unlock()
426 d.paint(from, ac, true)428 d.paint(from, ac, true)
427- return429+ return false
428 }430 }
429 from.mu.Lock()431 from.mu.Lock()
430 if from.client != ac {432 if from.client != ac {
431 from.mu.Unlock()433 from.mu.Unlock()
432 d.mu.Unlock()434 d.mu.Unlock()
433- return435+ return false
434 }436 }
435 term := from.terminal437 term := from.terminal
436 cwd := d.dirOrHome(stopped.cwd)438 cwd := d.dirOrHome(stopped.cwd)
@@ -440,7 +442,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
440 d.mu.Unlock()442 d.mu.Unlock()
441 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
442 d.paint(from, ac, true)444 d.paint(from, ac, true)
443- return445+ return false
444 }446 }
445 from.client = nil447 from.client = nil
446 ac.setSession(nil)448 ac.setSession(nil)
@@ -448,10 +450,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
448 targetSess.mu.Lock()450 targetSess.mu.Lock()
449 targetSess.client = ac451 targetSess.client = ac
450 targetSess.mu.Unlock()452 targetSess.mu.Unlock()
453+
451 d.touchMRU(targetSess)454 d.touchMRU(targetSess)
452 ac.setSession(targetSess)455 ac.setSession(targetSess)
456+ ac.recordPreviousSession(from)
453 d.mu.Unlock()457 d.mu.Unlock()
454 d.firstPaint(targetSess, ac, ac.size)458 d.firstPaint(targetSess, ac, ac.size)
459+ return true
455 }460 }
456 461
457 func (d *Daemon) killPickerTarget(target picker.Target) {462 func (d *Daemon) killPickerTarget(target picker.Target) {
D internal/usecase/daemon/recent_nav.go +0 -163
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/picker"
9-)
10-
11-const historyNavDisplayTimeout = 1500 * time.Millisecond
12-
13-type recentSessionNavigator struct {
14- ids []domain.SessionID
15- index int
16-}
17-
18-type historyNavDisplay struct {
19- ids []domain.SessionID
20- index int
21- gen uint64
22-}
23-
24-func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25- h.ids = append(h.ids[:0], ids...)
26- h.index = index
27- h.gen = gen
28-}
29-
30-func (h *historyNavDisplay) clear() {
31- h.ids = h.ids[:0]
32- h.index = 0
33-}
34-
35-func (h historyNavDisplay) active() bool {
36- return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37-}
38-
39-func (n recentSessionNavigator) activeForDisplay() bool {
40- return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41-}
42-
43-func recentSessionTrail(cur *session, state barState) []domain.SessionID {
44- if cur == nil {
45- return nil
46- }
47- ids := make([]domain.SessionID, 0, 1+len(state.mru))
48- ids = append(ids, cur.id)
49- for _, entry := range state.mru {
50- if entry.id != "" {
51- ids = append(ids, entry.id)
52- }
53- }
54- return ids
55-}
56-
57-func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
58- n.ids = append(n.ids[:0], ids...)
59- n.index = 0
60-}
61-
62-func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
63- if d == nil || sess == nil || ac == nil || delta == 0 {
64- return
65- }
66- trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
67- if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
68- ac.recentNav.reset(trail)
69- }
70- if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
71- ac.recentNav.reset(trail)
72- }
73-
74- next := ac.recentNav.index + delta
75- if next < 0 || next >= len(ac.recentNav.ids) {
76- d.paint(sess, ac, true)
77- return
78- }
79- targetID := ac.recentNav.ids[next]
80- targetSess := d.sessionByID(targetID)
81- if targetSess == nil {
82- d.clearHistoryNav(ac)
83- d.paint(sess, ac, true)
84- return
85- }
86- d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
87- if ac.currentSession() == targetSess {
88- ac.recentNav.index = next
89- d.showHistoryNav(ac)
90- d.paint(targetSess, ac, true)
91- }
92-}
93-
94-func (d *Daemon) showHistoryNav(ac *attachedClient) {
95- if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96- return
97- }
98- ac.historyNavMu.Lock()
99- ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100- gen := ac.historyNav.gen
101- ac.historyNavTimer.stop()
102- ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103- ac.historyNavMu.Lock()
104- if ac.historyNav.gen != gen {
105- ac.historyNavMu.Unlock()
106- return
107- }
108- ac.historyNav.clear()
109- ac.historyNavMu.Unlock()
110- if sess := ac.currentSession(); sess != nil {
111- sess.mu.Lock()
112- stillCurrent := sess.client == ac
113- sess.mu.Unlock()
114- if stillCurrent {
115- d.paint(sess, ac, true)
116- }
117- }
118- })
119- ac.historyNavMu.Unlock()
120-}
121-
122-func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123- if ac == nil {
124- return
125- }
126- ac.clearHistoryNav()
127-}
128-
129-func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
130- if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
131- return false
132- }
133- current := n.ids[n.index]
134- kept := n.ids[:0]
135- nextIndex := -1
136- for _, id := range n.ids {
137- if d.sessionByID(id) == nil {
138- continue
139- }
140- if id == current {
141- nextIndex = len(kept)
142- }
143- kept = append(kept, id)
144- }
145- n.ids = kept
146- if nextIndex == -1 {
147- return false
148- }
149- n.index = nextIndex
150- return true
151-}
152-
153-func sameSessionTrail(a, b []domain.SessionID) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "time"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/picker"
9-)
10-
11-const historyNavDisplayTimeout = 1500 * time.Millisecond
12-
13-type recentSessionNavigator struct {
14- ids []domain.SessionID
15- index int
16-}
17-
18-type historyNavDisplay struct {
19- ids []domain.SessionID
20- index int
21- gen uint64
22-}
23-
24-func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25- h.ids = append(h.ids[:0], ids...)
26- h.index = index
27- h.gen = gen
28-}
29-
30-func (h *historyNavDisplay) clear() {
31- h.ids = h.ids[:0]
32- h.index = 0
33-}
34-
35-func (h historyNavDisplay) active() bool {
36- return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37-}
38-
39-func (n recentSessionNavigator) activeForDisplay() bool {
40- return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41-}
42-
43-func recentSessionTrail(cur *session, state barState) []domain.SessionID {
44- if cur == nil {
45- return nil
46- }
47- ids := make([]domain.SessionID, 0, 1+len(state.mru))
48- ids = append(ids, cur.id)
49- for _, entry := range state.mru {
50- if entry.id != "" {
51- ids = append(ids, entry.id)
52- }
53- }
54- return ids
55-}
56-
57-func (n *recentSessionNavigator) reset(ids []domain.SessionID) {
58- n.ids = append(n.ids[:0], ids...)
59- n.index = 0
60-}
61-
62-func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta int) {
63- if d == nil || sess == nil || ac == nil || delta == 0 {
64- return
65- }
66- trail := recentSessionTrail(sess, d.barStateFor(sess, ""))
67- if ac.recentNav.index == 0 && !sameSessionTrail(ac.recentNav.ids, trail) {
68- ac.recentNav.reset(trail)
69- }
70- if !d.pruneRecentNav(&ac.recentNav) || ac.recentNav.ids[ac.recentNav.index] != sess.id {
71- ac.recentNav.reset(trail)
72- }
73-
74- next := ac.recentNav.index + delta
75- if next < 0 || next >= len(ac.recentNav.ids) {
76- d.paint(sess, ac, true)
77- return
78- }
79- targetID := ac.recentNav.ids[next]
80- targetSess := d.sessionByID(targetID)
81- if targetSess == nil {
82- d.clearHistoryNav(ac)
83- d.paint(sess, ac, true)
84- return
85- }
86- d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
87- if ac.currentSession() == targetSess {
88- ac.recentNav.index = next
89- d.showHistoryNav(ac)
90- d.paint(targetSess, ac, true)
91- }
92-}
93-
94-func (d *Daemon) showHistoryNav(ac *attachedClient) {
95- if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96- return
97- }
98- ac.historyNavMu.Lock()
99- ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100- gen := ac.historyNav.gen
101- ac.historyNavTimer.stop()
102- ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103- ac.historyNavMu.Lock()
104- if ac.historyNav.gen != gen {
105- ac.historyNavMu.Unlock()
106- return
107- }
108- ac.historyNav.clear()
109- ac.historyNavMu.Unlock()
110- if sess := ac.currentSession(); sess != nil {
111- sess.mu.Lock()
112- stillCurrent := sess.client == ac
113- sess.mu.Unlock()
114- if stillCurrent {
115- d.paint(sess, ac, true)
116- }
117- }
118- })
119- ac.historyNavMu.Unlock()
120-}
121-
122-func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123- if ac == nil {
124- return
125- }
126- ac.clearHistoryNav()
127-}
128-
129-func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
130- if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
131- return false
132- }
133- current := n.ids[n.index]
134- kept := n.ids[:0]
135- nextIndex := -1
136- for _, id := range n.ids {
137- if d.sessionByID(id) == nil {
138- continue
139- }
140- if id == current {
141- nextIndex = len(kept)
142- }
143- kept = append(kept, id)
144- }
145- n.ids = kept
146- if nextIndex == -1 {
147- return false
148- }
149- n.index = nextIndex
150- return true
151-}
152-
153-func sameSessionTrail(a, b []domain.SessionID) bool {
154- if len(a) != len(b) {
155- return false
156- }
157- for i := range a {
158- if a[i] != b[i] {
159- return false
160- }
161- }
162- return true
163-}
A internal/usecase/daemon/recent_sessions.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// recentSession is an immutable, value-only description of a session for one
10+// interaction. It deliberately does not retain a live session pointer.
11+type recentSession struct {
12+ id domain.SessionID
13+ name string
14+ ephemeral bool
15+ attention bool
16+ mruAt uint64
17+}
18+
19+// recentSessions returns the current session's capped MRU list. Callers may
20+// retain the returned values for the lifetime of an interaction.
21+func (d *Daemon) recentSessions(current *session) []recentSession {
22+ if d == nil {
23+ return nil
24+ }
25+ d.mu.Lock()
26+ recent := make([]recentSession, 0, len(d.sessions))
27+ for _, sess := range d.sessions {
28+ if sess == current {
29+ continue
30+ }
31+ sess.mu.Lock()
32+ entry := recentSession{
33+ id: sess.id,
34+ name: sess.name,
35+ ephemeral: sess.ephemeral,
36+ mruAt: sess.mruAt.Load(),
37+ }
38+ for _, tb := range sess.tabs {
39+ if tb.attention {
40+ entry.attention = true
41+ break
42+ }
43+ }
44+ sess.mu.Unlock()
45+ recent = append(recent, entry)
46+ }
47+ d.mu.Unlock()
48+ sort.SliceStable(recent, func(i, j int) bool {
49+ if recent[i].mruAt == recent[j].mruAt {
50+ return recent[i].name < recent[j].name
51+ }
52+ return recent[i].mruAt > recent[j].mruAt
53+ })
54+ if len(recent) > maxMRUSessions {
55+ recent = recent[:maxMRUSessions]
56+ }
57+ return recent
58+}
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// recentSession is an immutable, value-only description of a session for one
10+// interaction. It deliberately does not retain a live session pointer.
11+type recentSession struct {
12+ id domain.SessionID
13+ name string
14+ ephemeral bool
15+ attention bool
16+ mruAt uint64
17+}
18+
19+// recentSessions returns the current session's capped MRU list. Callers may
20+// retain the returned values for the lifetime of an interaction.
21+func (d *Daemon) recentSessions(current *session) []recentSession {
22+ if d == nil {
23+ return nil
24+ }
25+ d.mu.Lock()
26+ recent := make([]recentSession, 0, len(d.sessions))
27+ for _, sess := range d.sessions {
28+ if sess == current {
29+ continue
30+ }
31+ sess.mu.Lock()
32+ entry := recentSession{
33+ id: sess.id,
34+ name: sess.name,
35+ ephemeral: sess.ephemeral,
36+ mruAt: sess.mruAt.Load(),
37+ }
38+ for _, tb := range sess.tabs {
39+ if tb.attention {
40+ entry.attention = true
41+ break
42+ }
43+ }
44+ sess.mu.Unlock()
45+ recent = append(recent, entry)
46+ }
47+ d.mu.Unlock()
48+ sort.SliceStable(recent, func(i, j int) bool {
49+ if recent[i].mruAt == recent[j].mruAt {
50+ return recent[i].name < recent[j].name
51+ }
52+ return recent[i].mruAt > recent[j].mruAt
53+ })
54+ if len(recent) > maxMRUSessions {
55+ recent = recent[:maxMRUSessions]
56+ }
57+ return recent
58+}
M internal/usecase/daemon/render.go +8 -2
@@ -368,7 +368,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
368368 preview = snapshotPickerPreview(overlays.previewTab)
369369 }
370370 d.refreshSessionFocusedTitles(sess)
371- bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
371+ // Contextual ranks are completely captured under paletteMu. Compose them
372+ // without reading the live MRU, whose order may have changed mid-interaction.
373+ bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
372374 bars.theme = ac.getTheme()
373375 attentionVisible := pulseVisible(bars.attentionFrame)
374376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
@@ -454,7 +456,11 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
454456 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
455457 }
456458 if overlays.paletteActive {
457- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)
459+ guidance := ""
460+ if overlays.paletteHints != nil {
461+ guidance = overlays.paletteHints.Feedback
462+ }
463+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles)
458464 }
459465 if overlays.promptActive {
460466 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
@@ -368,7 +368,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
368 preview = snapshotPickerPreview(overlays.previewTab)368 preview = snapshotPickerPreview(overlays.previewTab)
369 }369 }
370 d.refreshSessionFocusedTitles(sess)370 d.refreshSessionFocusedTitles(sess)
371- bars := d.barStateForClient(sess, ac, overlays.copyFeedback)371+ // Contextual ranks are completely captured under paletteMu. Compose them
372+ // without reading the live MRU, whose order may have changed mid-interaction.
373+ bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
372 bars.theme = ac.getTheme()374 bars.theme = ac.getTheme()
373 attentionVisible := pulseVisible(bars.attentionFrame)375 attentionVisible := pulseVisible(bars.attentionFrame)
374 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
@@ -454,7 +456,11 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
454 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)456 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
455 }457 }
456 if overlays.paletteActive {458 if overlays.paletteActive {
457- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles)459+ guidance := ""
460+ if overlays.paletteHints != nil {
461+ guidance = overlays.paletteHints.Feedback
462+ }
463+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles)
458 }464 }
459 if overlays.promptActive {465 if overlays.promptActive {
460 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)466 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
M internal/usecase/daemon/render_test.go +0 -65
@@ -1348,71 +1348,6 @@ func cursorCSI(row, col int) string {
13481348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
13491349 }
13501350
1351-func TestHistoryNavBottomBar(t *testing.T) {
1352- styles := newThemeStyles(themeui.Theme{})
1353-
1354- t.Run("frozen order active styling and right text", func(t *testing.T) {
1355- row := make([]renderer.Cell, 40)
1356- state := barState{
1357- bottomRight: "script",
1358- history: []historyNavSession{
1359- {id: "work", name: "work"},
1360- {id: "api", name: "api", active: true},
1361- {id: "docs", name: "docs", ephemeral: true},
1362- },
1363- }
1364-
1365- drawStatusBarState(row, state, styles)
1366-
1367- got := cellsString(row)
1368- require.Contains(t, got, " work api docs* ")
1369- require.Contains(t, got, " script")
1370- apiCol := strings.Index(got, "api")
1371- require.NotEqual(t, -1, apiCol)
1372- require.True(t, row[apiCol].Style.Equal(styles.accent))
1373- workCol := strings.Index(got, "work")
1374- require.NotEqual(t, -1, workCol)
1375- require.True(t, row[workCol].Style.Equal(styles.statusBar))
1376- })
1377-
1378- t.Run("empty history falls back to normal mru", func(t *testing.T) {
1379- row := make([]renderer.Cell, 30)
1380- state := barState{
1381- status: statusSnapshot{session: "work"},
1382- mru: []mruSession{{name: "api"}, {name: "docs"}},
1383- }
1384-
1385- drawStatusBarState(row, state, styles)
1386-
1387- got := cellsString(row)
1388- require.Contains(t, got, " work api docs ")
1389- workCol := strings.Index(got, "work")
1390- require.NotEqual(t, -1, workCol)
1391- require.True(t, row[workCol].Style.Equal(styles.accent))
1392- })
1393-
1394- t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1395- row := make([]renderer.Cell, 13)
1396- state := barState{history: []historyNavSession{
1397- {name: "work"},
1398- {name: "api"},
1399- {name: "docs", active: true},
1400- {name: "infra"},
1401- {name: "ops"},
1402- }}
1403-
1404- drawStatusBarState(row, state, styles)
1405-
1406- got := cellsString(row)
1407- require.Contains(t, got, "docs")
1408- require.NotContains(t, got, "work")
1409- require.NotContains(t, got, "ops")
1410- docsCol := strings.Index(got, "docs")
1411- require.NotEqual(t, -1, docsCol)
1412- require.True(t, row[docsCol].Style.Equal(styles.accent))
1413- })
1414-}
1415-
14161351 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14171352 base := renderer.NewFrame(20, 8)
14181353 for y := range base.Height {
@@ -1348,71 +1348,6 @@ func cursorCSI(row, col int) string {
1348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"1348 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
1349 }1349 }
1350 1350
1351-func TestHistoryNavBottomBar(t *testing.T) {
1352- styles := newThemeStyles(themeui.Theme{})
1353-
1354- t.Run("frozen order active styling and right text", func(t *testing.T) {
1355- row := make([]renderer.Cell, 40)
1356- state := barState{
1357- bottomRight: "script",
1358- history: []historyNavSession{
1359- {id: "work", name: "work"},
1360- {id: "api", name: "api", active: true},
1361- {id: "docs", name: "docs", ephemeral: true},
1362- },
1363- }
1364-
1365- drawStatusBarState(row, state, styles)
1366-
1367- got := cellsString(row)
1368- require.Contains(t, got, " work api docs* ")
1369- require.Contains(t, got, " script")
1370- apiCol := strings.Index(got, "api")
1371- require.NotEqual(t, -1, apiCol)
1372- require.True(t, row[apiCol].Style.Equal(styles.accent))
1373- workCol := strings.Index(got, "work")
1374- require.NotEqual(t, -1, workCol)
1375- require.True(t, row[workCol].Style.Equal(styles.statusBar))
1376- })
1377-
1378- t.Run("empty history falls back to normal mru", func(t *testing.T) {
1379- row := make([]renderer.Cell, 30)
1380- state := barState{
1381- status: statusSnapshot{session: "work"},
1382- mru: []mruSession{{name: "api"}, {name: "docs"}},
1383- }
1384-
1385- drawStatusBarState(row, state, styles)
1386-
1387- got := cellsString(row)
1388- require.Contains(t, got, " work api docs ")
1389- workCol := strings.Index(got, "work")
1390- require.NotEqual(t, -1, workCol)
1391- require.True(t, row[workCol].Style.Equal(styles.accent))
1392- })
1393-
1394- t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1395- row := make([]renderer.Cell, 13)
1396- state := barState{history: []historyNavSession{
1397- {name: "work"},
1398- {name: "api"},
1399- {name: "docs", active: true},
1400- {name: "infra"},
1401- {name: "ops"},
1402- }}
1403-
1404- drawStatusBarState(row, state, styles)
1405-
1406- got := cellsString(row)
1407- require.Contains(t, got, "docs")
1408- require.NotContains(t, got, "work")
1409- require.NotContains(t, got, "ops")
1410- docsCol := strings.Index(got, "docs")
1411- require.NotEqual(t, -1, docsCol)
1412- require.True(t, row[docsCol].Style.Equal(styles.accent))
1413- })
1414-}
1415-
1416 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {1351 func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1417 base := renderer.NewFrame(20, 8)1352 base := renderer.NewFrame(20, 8)
1418 for y := range base.Height {1353 for y := range base.Height {
M internal/usecase/daemon/resume.go +1 -0
@@ -96,6 +96,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
9696 // has verified d.parked[token] still points at parked when that matters.
9797 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
9898 delete(d.parked, token)
99+ parked.ac.clearPreviousSession()
99100 parked.ac.resumeToken = 0
100101 parked.ac.parked = false
101102 if parked.timer != nil {
@@ -96,6 +96,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
96 // has verified d.parked[token] still points at parked when that matters.96 // has verified d.parked[token] still points at parked when that matters.
97 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {97 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
98 delete(d.parked, token)98 delete(d.parked, token)
99+ parked.ac.clearPreviousSession()
99 parked.ac.resumeToken = 0100 parked.ac.resumeToken = 0
100 parked.ac.parked = false101 parked.ac.parked = false
101 if parked.timer != nil {102 if parked.timer != nil {
M internal/usecase/daemon/resume_test.go +67 -0
@@ -322,6 +322,73 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
322322 require.Zero(t, parked)
323323 }
324324
325+func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
326+ pty, release := newBlockingPTY(t)
327+ defer release()
328+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
329+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
330+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
331+ require.NoError(t, err)
332+
333+ previous := &session{id: "previous"}
334+ ac.previousSession.Set(previous)
335+ d.clientGone(sess, ac, ac.transport(), false)
336+
337+ token := ac.resumeToken
338+ d.mu.Lock()
339+ parked := d.parked[token]
340+ d.mu.Unlock()
341+ require.NotNil(t, parked)
342+ require.Same(t, previous, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
343+
344+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
345+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
346+ require.NoError(t, err)
347+ require.Same(t, sess, resumedSess)
348+ require.Same(t, ac, resumedAC)
349+ require.Same(t, previous, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
350+}
351+
352+func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
353+ for _, tc := range []struct {
354+ name string
355+ discard func(*Daemon, uint64, *parkedAttachment)
356+ }{
357+ {
358+ name: "expiry",
359+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) {
360+ d.removeParkedLocked(token, parked)
361+ },
362+ },
363+ {
364+ name: "session purge",
365+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {
366+ d.purgeParkedForSessionLocked(parked.sess)
367+ },
368+ },
369+ } {
370+ t.Run(tc.name, func(t *testing.T) {
371+ pty, release := newBlockingPTY(t)
372+ defer release()
373+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
374+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
375+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
376+ require.NoError(t, err)
377+
378+ ac.previousSession.Set(&session{id: "previous"})
379+ d.clientGone(sess, ac, ac.transport(), false)
380+
381+ d.mu.Lock()
382+ parked := d.parked[ac.resumeToken]
383+ require.NotNil(t, parked)
384+ tc.discard(d, ac.resumeToken, parked)
385+ d.mu.Unlock()
386+
387+ require.Nil(t, ac.previousSession.Get())
388+ })
389+ }
390+}
391+
325392 func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
326393 clk := &signalClock{timers: make(chan *signalTimer, 8)}
327394 pty, release := newBlockingPTY(t)
@@ -322,6 +322,73 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
322 require.Zero(t, parked)322 require.Zero(t, parked)
323 }323 }
324 324
325+func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
326+ pty, release := newBlockingPTY(t)
327+ defer release()
328+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
329+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
330+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
331+ require.NoError(t, err)
332+
333+ previous := &session{id: "previous"}
334+ ac.previousSession.Set(previous)
335+ d.clientGone(sess, ac, ac.transport(), false)
336+
337+ token := ac.resumeToken
338+ d.mu.Lock()
339+ parked := d.parked[token]
340+ d.mu.Unlock()
341+ require.NotNil(t, parked)
342+ require.Same(t, previous, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
343+
344+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
345+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
346+ require.NoError(t, err)
347+ require.Same(t, sess, resumedSess)
348+ require.Same(t, ac, resumedAC)
349+ require.Same(t, previous, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
350+}
351+
352+func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
353+ for _, tc := range []struct {
354+ name string
355+ discard func(*Daemon, uint64, *parkedAttachment)
356+ }{
357+ {
358+ name: "expiry",
359+ discard: func(d *Daemon, token uint64, parked *parkedAttachment) {
360+ d.removeParkedLocked(token, parked)
361+ },
362+ },
363+ {
364+ name: "session purge",
365+ discard: func(d *Daemon, _ uint64, parked *parkedAttachment) {
366+ d.purgeParkedForSessionLocked(parked.sess)
367+ },
368+ },
369+ } {
370+ t.Run(tc.name, func(t *testing.T) {
371+ pty, release := newBlockingPTY(t)
372+ defer release()
373+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
374+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
375+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
376+ require.NoError(t, err)
377+
378+ ac.previousSession.Set(&session{id: "previous"})
379+ d.clientGone(sess, ac, ac.transport(), false)
380+
381+ d.mu.Lock()
382+ parked := d.parked[ac.resumeToken]
383+ require.NotNil(t, parked)
384+ tc.discard(d, ac.resumeToken, parked)
385+ d.mu.Unlock()
386+
387+ require.Nil(t, ac.previousSession.Get())
388+ })
389+ }
390+}
391+
325 func TestEphemeralParkExpiryKeepsSession(t *testing.T) {392 func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
326 clk := &signalClock{timers: make(chan *signalTimer, 8)}393 clk := &signalClock{timers: make(chan *signalTimer, 8)}
327 pty, release := newBlockingPTY(t)394 pty, release := newBlockingPTY(t)
M internal/usecase/daemon/session.go +3 -0
@@ -261,8 +261,10 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
261261 newSess.mu.Lock()
262262 newSess.client = ac
263263 newSess.mu.Unlock()
264+
264265 d.touchMRU(newSess)
265266 ac.setSession(newSess)
267+ ac.recordPreviousSession(from)
266268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
267269 d.mu.Unlock()
268270
@@ -732,6 +734,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
732734 if ac != nil {
733735 ac.cancelResizePaint()
734736 d.unregisterPreview(ac)
737+ ac.clearPreviousSession()
735738 ac.setSession(nil)
736739 }
737740
@@ -261,8 +261,10 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
261 newSess.mu.Lock()261 newSess.mu.Lock()
262 newSess.client = ac262 newSess.client = ac
263 newSess.mu.Unlock()263 newSess.mu.Unlock()
264+
264 d.touchMRU(newSess)265 d.touchMRU(newSess)
265 ac.setSession(newSess)266 ac.setSession(newSess)
267+ ac.recordPreviousSession(from)
266 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
267 d.mu.Unlock()269 d.mu.Unlock()
268 270
@@ -732,6 +734,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
732 if ac != nil {734 if ac != nil {
733 ac.cancelResizePaint()735 ac.cancelResizePaint()
734 d.unregisterPreview(ac)736 d.unregisterPreview(ac)
737+ ac.clearPreviousSession()
735 ac.setSession(nil)738 ac.setSession(nil)
736 }739 }
737 740
A internal/usecase/daemon/session_back.go +40 -0
@@ -0,0 +1,40 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/picker"
4+
5+// backSession toggles this attachment between its current session and the
6+// immediately preceding successfully activated session.
7+func (d *Daemon) backSession(current *session, ac *attachedClient) {
8+ if d == nil || current == nil || ac == nil {
9+ return
10+ }
11+ target := ac.previousSession.Get()
12+ if target == nil || target == current || d.sessionByID(target.id) != target {
13+ ac.clearPreviousSessionIf(target)
14+ d.paint(current, ac, true)
15+ return
16+ }
17+ d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
18+}
19+
20+// clearPreviousSessionIf clears target only if it has not been replaced since
21+// the caller observed it. This preserves a concurrent successful hand-off.
22+func (ac *attachedClient) clearPreviousSessionIf(target *session) {
23+ if ac == nil {
24+ return
25+ }
26+ ac.previousSession.With(func(previous **session) {
27+ if *previous == target {
28+ *previous = nil
29+ }
30+ })
31+}
32+
33+// recordPreviousSession is called only after a cross-session hand-off has
34+// committed. Keeping this after the commit makes failed/displaced switches
35+// leave the toggle pair intact.
36+func (ac *attachedClient) recordPreviousSession(origin *session) {
37+ if ac != nil && origin != nil {
38+ ac.previousSession.Set(origin)
39+ }
40+}
@@ -0,0 +1,40 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/usecase/picker"
4+
5+// backSession toggles this attachment between its current session and the
6+// immediately preceding successfully activated session.
7+func (d *Daemon) backSession(current *session, ac *attachedClient) {
8+ if d == nil || current == nil || ac == nil {
9+ return
10+ }
11+ target := ac.previousSession.Get()
12+ if target == nil || target == current || d.sessionByID(target.id) != target {
13+ ac.clearPreviousSessionIf(target)
14+ d.paint(current, ac, true)
15+ return
16+ }
17+ d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
18+}
19+
20+// clearPreviousSessionIf clears target only if it has not been replaced since
21+// the caller observed it. This preserves a concurrent successful hand-off.
22+func (ac *attachedClient) clearPreviousSessionIf(target *session) {
23+ if ac == nil {
24+ return
25+ }
26+ ac.previousSession.With(func(previous **session) {
27+ if *previous == target {
28+ *previous = nil
29+ }
30+ })
31+}
32+
33+// recordPreviousSession is called only after a cross-session hand-off has
34+// committed. Keeping this after the commit makes failed/displaced switches
35+// leave the toggle pair intact.
36+func (ac *attachedClient) recordPreviousSession(origin *session) {
37+ if ac != nil && origin != nil {
38+ ac.previousSession.Set(origin)
39+ }
40+}
M internal/usecase/daemon/status.go +111 -181
@@ -19,11 +19,9 @@
1919 //
2020 // Locking: a pane's screen/scrollback and per-client renderer shadow are
2121 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. History-navigation display state is
23-// guarded by attachedClient.historyNavMu. When more than one is held the order
24-// is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu >
25-// session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
26-// never blocks on a slow client.
22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
2725 package daemon
2826
2927 import (
@@ -31,7 +29,8 @@ import (
3129 "strconv"
3230 "time"
3331
34- "github.com/bnema/vev/internal/domain"
32+ "github.com/bnema/vev/internal/usecase/command"
33+ "github.com/bnema/vev/internal/usecase/palette"
3534 themeui "github.com/bnema/vev/internal/usecase/theme"
3635 "github.com/bnema/vev/internal/usecase/ui"
3736 "github.com/bnema/vev/pkg/renderer"
@@ -104,30 +103,47 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
104103 clearStatusRow(row)
105104 x := 0
106105 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
107- if len(state.history) > 0 {
108- drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
109- drawRightPlainText(row, rightText, x, styles.statusBar)
110- return
111- }
112-
113106 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
114-
115- fittedMRU := fitMRU(state.mru, len(row), x, rightText)
116- for i, sess := range fittedMRU {
117- style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
118- drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)
107+ if state.rankedRecent != nil {
108+ for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
109+ style := styles.statusBar // contextual ranks deliberately do not fade.
110+ if sess.selected {
111+ style = styles.accent
112+ }
113+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
114+ }
115+ } else {
116+ fittedMRU := fitMRU(state.mru, len(row), x, rightText)
117+ for i, sess := range fittedMRU {
118+ style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
119+ drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)
120+ }
119121 }
120122 drawRightPlainText(row, rightText, x, styles.statusBar)
121123 }
122124
123-func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
124- for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
125- style := styles.statusBar
126- if sess.active {
127- style = styles.accent
128- }
129- drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame)
125+type rankedRecent struct {
126+ rank int
127+ name string
128+ ephemeral bool
129+ attention bool
130+ selected bool
131+}
132+
133+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {
134+ prefixStyle := style
135+ prefixStyle.Bold = true
136+ writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
137+ name := sess.name
138+ if sess.ephemeral {
139+ name += "*"
140+ }
141+ writeStatusText(row, x, name, style)
142+ if sess.attention {
143+ writeStatusText(row, x, " ", style)
144+ writeBell(row, x, attentionFrame, style)
130145 }
146+ writeStatusText(row, x, " ", style)
131147 }
132148
133149 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {
@@ -180,8 +196,8 @@ type barState struct {
180196 topRight string
181197 bottomRight string
182198 copyFeedback string
183- mru []mruSession
184- history []historyNavSession
199+ mru []recentSession
200+ rankedRecent []rankedRecent
185201 attentionFrame int
186202 // theme is the client's terminal theme, if reported. Its zero value
187203 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -189,23 +205,6 @@ type barState struct {
189205 theme themeui.Theme
190206 }
191207
192-type mruSession struct {
193- id domain.SessionID
194- name string
195- ephemeral bool
196- attention bool
197- // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
198- mruAt uint64
199-}
200-
201-type historyNavSession struct {
202- id domain.SessionID
203- name string
204- ephemeral bool
205- attention bool
206- active bool
207-}
208-
209208 type statusSnapshot struct {
210209 session string
211210 tabs []statusTab
@@ -242,54 +241,51 @@ func tabDisplayName(tb *tab, index int) string {
242241 return strconv.Itoa(index + 1)
243242 }
244243
245-func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
246- state := d.barStateFor(cur, copyFeedback)
247- state.history = d.historyNavBarState(ac)
248- return state
249-}
250-
251-func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
252- if d == nil || ac == nil {
253- return nil
254- }
255- ac.historyNavMu.Lock()
256- if !ac.historyNav.active() {
257- ac.historyNavMu.Unlock()
244+func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent {
245+ if hints == nil || hints.Kind != command.ContextHintRecentSessions {
258246 return nil
259247 }
260- ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
261- activeIndex := ac.historyNav.index
262- ac.historyNavMu.Unlock()
263-
264- d.mu.Lock()
265- out := make([]historyNavSession, 0, len(ids))
266- valid := true
267- for i, id := range ids {
268- sess := d.sessions[id]
269- if sess == nil {
270- valid = false
271- break
272- }
273- sess.mu.Lock()
274- entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
275- for _, tb := range sess.tabs {
276- if tb.attention {
277- entry.attention = true
278- break
279- }
248+ entries := make([]rankedRecent, 0, len(hints.Recent))
249+ for i, hint := range hints.Recent {
250+ entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank}
251+ // recent was copied with the hint under paletteMu, so this only enriches
252+ // the render snapshot and never performs a live domain lookup.
253+ if i < len(recent) {
254+ entry.ephemeral = recent[i].ephemeral
255+ entry.attention = recent[i].attention
280256 }
281- sess.mu.Unlock()
282- out = append(out, entry)
257+ entries = append(entries, entry)
283258 }
284- d.mu.Unlock()
285- if !valid {
286- d.clearHistoryNav(ac)
287- return nil
259+ return entries
260+}
261+
262+// barStateForPaletteHints selects snapshot-only contextual composition when a
263+// palette interaction has captured recent-session hints. Normal palette state
264+// continues to use the canonical, live MRU list.
265+func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
266+ ranked := rankedRecentForHints(hints, recent)
267+ if ranked != nil {
268+ return d.barStateForContextual(cur, copyFeedback, ranked)
288269 }
289- return out
270+ return d.barStateFor(cur, copyFeedback)
271+}
272+
273+// barStateForContextual composes ranks captured for the palette interaction.
274+// It deliberately does not call recentSessions: live MRU changes must not
275+// affect contextual rendering, and palette rendering must not acquire d.mu.
276+func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState {
277+ state := d.barStateBase(cur, copyFeedback)
278+ state.rankedRecent = ranked
279+ return state
290280 }
291281
292282 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
283+ state := d.barStateBase(cur, copyFeedback)
284+ state.mru = d.recentSessions(cur)
285+ return state
286+}
287+
288+func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
293289 state := barState{copyFeedback: copyFeedback}
294290 if d != nil {
295291 state.attentionFrame = d.attentionFrame()
@@ -304,105 +300,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
304300 if d != nil {
305301 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
306302 }
307- if d == nil {
308- return state
309- }
310- d.mu.Lock()
311- mru := make([]mruSession, 0, len(d.sessions))
312- for _, sess := range d.sessions {
313- if sess == cur {
314- continue
315- }
316- at := sess.mruAt.Load()
317- sess.mu.Lock()
318- entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
319- for _, tb := range sess.tabs {
320- if tb.attention {
321- entry.attention = true
322- break
323- }
324- }
325- sess.mu.Unlock()
326- mru = append(mru, entry)
327- }
328- d.mu.Unlock()
329- sort.SliceStable(mru, func(i, j int) bool {
330- if mru[i].mruAt == mru[j].mruAt {
331- return mru[i].name < mru[j].name
332- }
333- return mru[i].mruAt > mru[j].mruAt
334- })
335- if len(mru) > maxMRUSessions {
336- mru = mru[:maxMRUSessions]
337- }
338- state.mru = mru
339303 return state
340304 }
341305
342-func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
343- copyReserve := 1
344- if feedback != "" {
345- copyReserve = statusTextWidth(feedback) + 2
346- }
347- budget := rowLen - leftUsed - copyReserve
348- if budget <= 0 || len(entries) == 0 {
349- return nil
350- }
351- cost := func(e historyNavSession) int {
352- name := e.name
353- if e.ephemeral {
354- name += "*"
355- }
356- n := 2 + statusTextWidth(name)
357- if e.attention {
358- n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
359- }
360- return n
361- }
362- used := 0
363- for _, e := range entries {
364- used += cost(e)
365- }
366- if used <= budget {
367- return entries
368- }
369- active := 0
370- for i, e := range entries {
371- if e.active {
372- active = i
373- break
374- }
375- }
376- if cost(entries[active]) > budget {
377- return nil
378- }
379- start, end := active, active+1
380- used = cost(entries[active])
381- for {
382- expanded := false
383- if start > 0 {
384- c := cost(entries[start-1])
385- if used+c <= budget {
386- start--
387- used += c
388- expanded = true
389- }
390- }
391- if end < len(entries) {
392- c := cost(entries[end])
393- if used+c <= budget {
394- end++
395- used += c
396- expanded = true
397- }
398- }
399- if !expanded {
400- break
401- }
402- }
403- return entries[start:end]
404-}
405-
406306 // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
407307 // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
408308 // always a byte offset landing on a rune boundary, since it is derived from
@@ -477,7 +377,37 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLab
477377 return labels
478378 }
479379
480-func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
380+func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
381+ reserve := 1
382+ if feedback != "" {
383+ reserve = statusTextWidth(feedback) + 2
384+ }
385+ budget := rowLen - leftUsed - reserve
386+ if budget <= 0 {
387+ return nil
388+ }
389+ cost := func(e rankedRecent) int {
390+ name := e.name
391+ if e.ephemeral {
392+ name += "*"
393+ }
394+ width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name)
395+ if e.attention {
396+ width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
397+ }
398+ return width
399+ }
400+ used := 0
401+ for i, entry := range entries {
402+ if used+cost(entry) > budget {
403+ return entries[:i] // whole entries only; ranks remain their original MRU ranks.
404+ }
405+ used += cost(entry)
406+ }
407+ return entries
408+}
409+
410+func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {
481411 // With no feedback, keep one blank trailing cell; with feedback, reserve
482412 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
483413 copyReserve := 1
@@ -488,7 +418,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
488418 if physicalBudget <= 0 || len(entries) == 0 {
489419 return nil
490420 }
491- cost := func(e mruSession) int {
421+ cost := func(e recentSession) int {
492422 name := e.name
493423 if e.ephemeral {
494424 name += "*"
@@ -19,11 +19,9 @@
19 //19 //
20 // Locking: a pane's screen/scrollback and per-client renderer shadow are20 // Locking: a pane's screen/scrollback and per-client renderer shadow are
21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. History-navigation display state is22+// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// guarded by attachedClient.historyNavMu. When more than one is held the order23+// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu >24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it
26-// never blocks on a slow client.
27 package daemon25 package daemon
28 26
29 import (27 import (
@@ -31,7 +29,8 @@ import (
31 "strconv"29 "strconv"
32 "time"30 "time"
33 31
34- "github.com/bnema/vev/internal/domain"32+ "github.com/bnema/vev/internal/usecase/command"
33+ "github.com/bnema/vev/internal/usecase/palette"
35 themeui "github.com/bnema/vev/internal/usecase/theme"34 themeui "github.com/bnema/vev/internal/usecase/theme"
36 "github.com/bnema/vev/internal/usecase/ui"35 "github.com/bnema/vev/internal/usecase/ui"
37 "github.com/bnema/vev/pkg/renderer"36 "github.com/bnema/vev/pkg/renderer"
@@ -104,30 +103,47 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
104 clearStatusRow(row)103 clearStatusRow(row)
105 x := 0104 x := 0
106 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)105 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
107- if len(state.history) > 0 {
108- drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
109- drawRightPlainText(row, rightText, x, styles.statusBar)
110- return
111- }
112-
113 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)106 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
114-107+ if state.rankedRecent != nil {
115- fittedMRU := fitMRU(state.mru, len(row), x, rightText)108+ for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
116- for i, sess := range fittedMRU {109+ style := styles.statusBar // contextual ranks deliberately do not fade.
117- style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))110+ if sess.selected {
118- drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)111+ style = styles.accent
112+ }
113+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
114+ }
115+ } else {
116+ fittedMRU := fitMRU(state.mru, len(row), x, rightText)
117+ for i, sess := range fittedMRU {
118+ style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
119+ drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)
120+ }
119 }121 }
120 drawRightPlainText(row, rightText, x, styles.statusBar)122 drawRightPlainText(row, rightText, x, styles.statusBar)
121 }123 }
122 124
123-func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {125+type rankedRecent struct {
124- for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {126+ rank int
125- style := styles.statusBar127+ name string
126- if sess.active {128+ ephemeral bool
127- style = styles.accent129+ attention bool
128- }130+ selected bool
129- drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame)131+}
132+
133+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {
134+ prefixStyle := style
135+ prefixStyle.Bold = true
136+ writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
137+ name := sess.name
138+ if sess.ephemeral {
139+ name += "*"
140+ }
141+ writeStatusText(row, x, name, style)
142+ if sess.attention {
143+ writeStatusText(row, x, " ", style)
144+ writeBell(row, x, attentionFrame, style)
130 }145 }
146+ writeStatusText(row, x, " ", style)
131 }147 }
132 148
133 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {149 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {
@@ -180,8 +196,8 @@ type barState struct {
180 topRight string196 topRight string
181 bottomRight string197 bottomRight string
182 copyFeedback string198 copyFeedback string
183- mru []mruSession199+ mru []recentSession
184- history []historyNavSession200+ rankedRecent []rankedRecent
185 attentionFrame int201 attentionFrame int
186 // theme is the client's terminal theme, if reported. Its zero value202 // theme is the client's terminal theme, if reported. Its zero value
187 // (Theme{}, Known: false) is a valid "no theme" default that resolves to203 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -189,23 +205,6 @@ type barState struct {
189 theme themeui.Theme205 theme themeui.Theme
190 }206 }
191 207
192-type mruSession struct {
193- id domain.SessionID
194- name string
195- ephemeral bool
196- attention bool
197- // mruAt orders entries in barStateFor (freshest first); drawing ignores it.
198- mruAt uint64
199-}
200-
201-type historyNavSession struct {
202- id domain.SessionID
203- name string
204- ephemeral bool
205- attention bool
206- active bool
207-}
208-
209 type statusSnapshot struct {208 type statusSnapshot struct {
210 session string209 session string
211 tabs []statusTab210 tabs []statusTab
@@ -242,54 +241,51 @@ func tabDisplayName(tb *tab, index int) string {
242 return strconv.Itoa(index + 1)241 return strconv.Itoa(index + 1)
243 }242 }
244 243
245-func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {244+func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent {
246- state := d.barStateFor(cur, copyFeedback)245+ if hints == nil || hints.Kind != command.ContextHintRecentSessions {
247- state.history = d.historyNavBarState(ac)
248- return state
249-}
250-
251-func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
252- if d == nil || ac == nil {
253- return nil
254- }
255- ac.historyNavMu.Lock()
256- if !ac.historyNav.active() {
257- ac.historyNavMu.Unlock()
258 return nil246 return nil
259 }247 }
260- ids := append([]domain.SessionID(nil), ac.historyNav.ids...)248+ entries := make([]rankedRecent, 0, len(hints.Recent))
261- activeIndex := ac.historyNav.index249+ for i, hint := range hints.Recent {
262- ac.historyNavMu.Unlock()250+ entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank}
263-251+ // recent was copied with the hint under paletteMu, so this only enriches
264- d.mu.Lock()252+ // the render snapshot and never performs a live domain lookup.
265- out := make([]historyNavSession, 0, len(ids))253+ if i < len(recent) {
266- valid := true254+ entry.ephemeral = recent[i].ephemeral
267- for i, id := range ids {255+ entry.attention = recent[i].attention
268- sess := d.sessions[id]
269- if sess == nil {
270- valid = false
271- break
272- }
273- sess.mu.Lock()
274- entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
275- for _, tb := range sess.tabs {
276- if tb.attention {
277- entry.attention = true
278- break
279- }
280 }256 }
281- sess.mu.Unlock()257+ entries = append(entries, entry)
282- out = append(out, entry)
283 }258 }
284- d.mu.Unlock()259+ return entries
285- if !valid {260+}
286- d.clearHistoryNav(ac)261+
287- return nil262+// barStateForPaletteHints selects snapshot-only contextual composition when a
263+// palette interaction has captured recent-session hints. Normal palette state
264+// continues to use the canonical, live MRU list.
265+func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
266+ ranked := rankedRecentForHints(hints, recent)
267+ if ranked != nil {
268+ return d.barStateForContextual(cur, copyFeedback, ranked)
288 }269 }
289- return out270+ return d.barStateFor(cur, copyFeedback)
271+}
272+
273+// barStateForContextual composes ranks captured for the palette interaction.
274+// It deliberately does not call recentSessions: live MRU changes must not
275+// affect contextual rendering, and palette rendering must not acquire d.mu.
276+func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState {
277+ state := d.barStateBase(cur, copyFeedback)
278+ state.rankedRecent = ranked
279+ return state
290 }280 }
291 281
292 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {282 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
283+ state := d.barStateBase(cur, copyFeedback)
284+ state.mru = d.recentSessions(cur)
285+ return state
286+}
287+
288+func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
293 state := barState{copyFeedback: copyFeedback}289 state := barState{copyFeedback: copyFeedback}
294 if d != nil {290 if d != nil {
295 state.attentionFrame = d.attentionFrame()291 state.attentionFrame = d.attentionFrame()
@@ -304,105 +300,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
304 if d != nil {300 if d != nil {
305 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)301 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
306 }302 }
307- if d == nil {
308- return state
309- }
310- d.mu.Lock()
311- mru := make([]mruSession, 0, len(d.sessions))
312- for _, sess := range d.sessions {
313- if sess == cur {
314- continue
315- }
316- at := sess.mruAt.Load()
317- sess.mu.Lock()
318- entry := mruSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, mruAt: at}
319- for _, tb := range sess.tabs {
320- if tb.attention {
321- entry.attention = true
322- break
323- }
324- }
325- sess.mu.Unlock()
326- mru = append(mru, entry)
327- }
328- d.mu.Unlock()
329- sort.SliceStable(mru, func(i, j int) bool {
330- if mru[i].mruAt == mru[j].mruAt {
331- return mru[i].name < mru[j].name
332- }
333- return mru[i].mruAt > mru[j].mruAt
334- })
335- if len(mru) > maxMRUSessions {
336- mru = mru[:maxMRUSessions]
337- }
338- state.mru = mru
339 return state303 return state
340 }304 }
341 305
342-func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
343- copyReserve := 1
344- if feedback != "" {
345- copyReserve = statusTextWidth(feedback) + 2
346- }
347- budget := rowLen - leftUsed - copyReserve
348- if budget <= 0 || len(entries) == 0 {
349- return nil
350- }
351- cost := func(e historyNavSession) int {
352- name := e.name
353- if e.ephemeral {
354- name += "*"
355- }
356- n := 2 + statusTextWidth(name)
357- if e.attention {
358- n += 1 + renderer.RuneWidth(ui.AttentionGlyph)
359- }
360- return n
361- }
362- used := 0
363- for _, e := range entries {
364- used += cost(e)
365- }
366- if used <= budget {
367- return entries
368- }
369- active := 0
370- for i, e := range entries {
371- if e.active {
372- active = i
373- break
374- }
375- }
376- if cost(entries[active]) > budget {
377- return nil
378- }
379- start, end := active, active+1
380- used = cost(entries[active])
381- for {
382- expanded := false
383- if start > 0 {
384- c := cost(entries[start-1])
385- if used+c <= budget {
386- start--
387- used += c
388- expanded = true
389- }
390- }
391- if end < len(entries) {
392- c := cost(entries[end])
393- if used+c <= budget {
394- end++
395- used += c
396- expanded = true
397- }
398- }
399- if !expanded {
400- break
401- }
402- }
403- return entries[start:end]
404-}
405-
406 // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name306 // fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
407 // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is307 // segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
408 // always a byte offset landing on a rune boundary, since it is derived from308 // always a byte offset landing on a rune boundary, since it is derived from
@@ -477,7 +377,37 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLab
477 return labels377 return labels
478 }378 }
479 379
480-func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {380+func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
381+ reserve := 1
382+ if feedback != "" {
383+ reserve = statusTextWidth(feedback) + 2
384+ }
385+ budget := rowLen - leftUsed - reserve
386+ if budget <= 0 {
387+ return nil
388+ }
389+ cost := func(e rankedRecent) int {
390+ name := e.name
391+ if e.ephemeral {
392+ name += "*"
393+ }
394+ width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name)
395+ if e.attention {
396+ width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
397+ }
398+ return width
399+ }
400+ used := 0
401+ for i, entry := range entries {
402+ if used+cost(entry) > budget {
403+ return entries[:i] // whole entries only; ranks remain their original MRU ranks.
404+ }
405+ used += cost(entry)
406+ }
407+ return entries
408+}
409+
410+func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {
481 // With no feedback, keep one blank trailing cell; with feedback, reserve411 // With no feedback, keep one blank trailing cell; with feedback, reserve
482 // its " text" width plus a one-cell gap so drawRightPlainText always fits.412 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
483 copyReserve := 1413 copyReserve := 1
@@ -488,7 +418,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
488 if physicalBudget <= 0 || len(entries) == 0 {418 if physicalBudget <= 0 || len(entries) == 0 {
489 return nil419 return nil
490 }420 }
491- cost := func(e mruSession) int {421+ cost := func(e recentSession) int {
492 name := e.name422 name := e.name
493 if e.ephemeral {423 if e.ephemeral {
494 name += "*"424 name += "*"
M internal/usecase/daemon/status_test.go +87 -13
@@ -13,7 +13,9 @@ import (
1313
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/usecase/command"
1617 "github.com/bnema/vev/internal/usecase/layout"
18+ "github.com/bnema/vev/internal/usecase/palette"
1719 themeui "github.com/bnema/vev/internal/usecase/theme"
1820 "github.com/bnema/vev/internal/usecase/ui"
1921 "github.com/bnema/vev/pkg/renderer"
@@ -935,6 +937,44 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
935937 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
936938 }
937939
940+func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
941+ p, releasePTY := newBlockingPTY(t)
942+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
943+ defer releasePTY()
944+ live := &session{id: "live", name: "live", tabs: []*tab{{}}}
945+ live.mruAt.Store(1)
946+ d.sessions[live.id] = live
947+
948+ hints := palette.ContextualHints{
949+ Kind: command.ContextHintRecentSessions,
950+ SelectedRank: 1,
951+ Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
952+ }
953+ // Hold the registry lock: a call to recentSessions would block here. The
954+ // contextual snapshot must still compose because it has no live MRU lookup.
955+ var contextual barState
956+ done := make(chan struct{})
957+ d.mu.Lock()
958+ go func() {
959+ contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", ephemeral: true, attention: true}})
960+ close(done)
961+ }()
962+ select {
963+ case <-done:
964+ case <-time.After(time.Second):
965+ d.mu.Unlock()
966+ t.Fatal("contextual composition consulted the live session registry")
967+ }
968+ d.mu.Unlock()
969+
970+ require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
971+ require.Equal(t, []rankedRecent{{rank: 1, name: "captured", ephemeral: true, attention: true, selected: true}}, contextual.rankedRecent)
972+
973+ normal := d.barStateFor(sess, "")
974+ require.Len(t, normal.mru, 1)
975+ require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
976+}
977+
938978 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
939979 p, releasePTY := newBlockingPTY(t)
940980 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -999,7 +1039,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
9991039 state := barState{
10001040 status: statusSnapshot{session: "cur"},
10011041 attentionFrame: 1,
1002- mru: []mruSession{
1042+ mru: []recentSession{
10031043 {name: "fresh"},
10041044 {name: "tmp", ephemeral: true, attention: true},
10051045 },
@@ -1031,10 +1071,44 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
10311071 require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
10321072 }
10331073
1074+func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1075+ styles := resolveThemeStyles(nil)
1076+ row := make([]renderer.Cell, 18)
1077+ state := barState{
1078+ status: statusSnapshot{session: "cur"},
1079+ rankedRecent: []rankedRecent{
1080+ {rank: 1, name: "wide界"},
1081+ {rank: 2, name: "two", selected: true},
1082+ {rank: 3, name: "three"},
1083+ },
1084+ }
1085+
1086+ drawStatusBarState(row, state, styles)
1087+ text := rowText(row)
1088+ require.Contains(t, text, "1:wide界")
1089+ require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name.
1090+ require.NotContains(t, text, "3:three")
1091+ // A rank that does fit is accent-colored and its prefix is bold.
1092+ row = make([]renderer.Cell, 28)
1093+ drawStatusBarState(row, state, styles)
1094+ require.Contains(t, rowText(row), "2:two")
1095+ found := false
1096+ for _, cell := range row {
1097+ if cell.Rune == '2' {
1098+ found = true
1099+ require.True(t, cell.Style.Bold)
1100+ require.Equal(t, styles.accent.Foreground, cell.Style.Foreground)
1101+ require.Equal(t, styles.accent.Background, cell.Style.Background)
1102+ break
1103+ }
1104+ }
1105+ require.True(t, found)
1106+}
1107+
10341108 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
10351109 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
10361110 styles := newThemeStyles(theme)
1037- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1111+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
10381112 row := make([]renderer.Cell, 16)
10391113
10401114 drawStatusBarState(row, state, styles)
@@ -1059,7 +1133,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
10591133 }
10601134
10611135 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1062- state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1136+ state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
10631137 row := make([]renderer.Cell, 21)
10641138
10651139 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1071,9 +1145,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
10711145 }
10721146
10731147 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1074- mru := make([]mruSession, 0, maxMRUSessions)
1148+ mru := make([]recentSession, 0, maxMRUSessions)
10751149 for i := 1; i <= maxMRUSessions; i++ {
1076- mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})
1150+ mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)})
10771151 }
10781152 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
10791153
@@ -1133,27 +1207,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
11331207 {
11341208 name: "script text and copy feedback",
11351209 width: 32,
1136- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
1210+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}},
11371211 wantContains: []string{" a"},
11381212 wantSuffix: " main ↑3 * copied",
11391213 },
11401214 {
11411215 name: "hide on overlap",
11421216 width: 16,
1143- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
1217+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
11441218 want: " cur ",
11451219 wantNotContains: []string{"main", "copied"},
11461220 },
11471221 {
11481222 name: "empty script keeps copy feedback behavior",
11491223 width: 12,
1150- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
1224+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
11511225 want: " cur copied",
11521226 },
11531227 {
11541228 name: "mru whole entry fitting with script text",
11551229 width: 24,
1156- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
1230+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
11571231 wantContains: []string{" fresh"},
11581232 wantNotContains: []string{"middle"},
11591233 wantSuffix: " git",
@@ -1161,7 +1235,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
11611235 {
11621236 name: "mru fitting reserves wide right anchor width",
11631237 width: 12,
1164- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
1238+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}},
11651239 wantNotContains: []string{" a "},
11661240 wantSuffix: " 界 界 ",
11671241 continuationCells: []int{9, 11},
@@ -1169,7 +1243,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
11691243 {
11701244 name: "mru fitting counts wide session names",
11711245 width: 20,
1172- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
1246+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}},
11731247 wantContains: []string{" 界 "},
11741248 wantNotContains: []string{" b "},
11751249 wantSuffix: " git",
@@ -1202,7 +1276,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
12021276 }
12031277
12041278 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1205- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1279+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
12061280 row := make([]renderer.Cell, 20)
12071281
12081282 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1215,7 +1289,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
12151289 }
12161290
12171291 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1218- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
1292+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
12191293 tests := []struct {
12201294 name string
12211295 cols int
@@ -13,7 +13,9 @@ import (
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/usecase/command"
16 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
18+ "github.com/bnema/vev/internal/usecase/palette"
17 themeui "github.com/bnema/vev/internal/usecase/theme"19 themeui "github.com/bnema/vev/internal/usecase/theme"
18 "github.com/bnema/vev/internal/usecase/ui"20 "github.com/bnema/vev/internal/usecase/ui"
19 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
@@ -935,6 +937,44 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
935 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))937 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
936 }938 }
937 939
940+func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
941+ p, releasePTY := newBlockingPTY(t)
942+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
943+ defer releasePTY()
944+ live := &session{id: "live", name: "live", tabs: []*tab{{}}}
945+ live.mruAt.Store(1)
946+ d.sessions[live.id] = live
947+
948+ hints := palette.ContextualHints{
949+ Kind: command.ContextHintRecentSessions,
950+ SelectedRank: 1,
951+ Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
952+ }
953+ // Hold the registry lock: a call to recentSessions would block here. The
954+ // contextual snapshot must still compose because it has no live MRU lookup.
955+ var contextual barState
956+ done := make(chan struct{})
957+ d.mu.Lock()
958+ go func() {
959+ contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", ephemeral: true, attention: true}})
960+ close(done)
961+ }()
962+ select {
963+ case <-done:
964+ case <-time.After(time.Second):
965+ d.mu.Unlock()
966+ t.Fatal("contextual composition consulted the live session registry")
967+ }
968+ d.mu.Unlock()
969+
970+ require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
971+ require.Equal(t, []rankedRecent{{rank: 1, name: "captured", ephemeral: true, attention: true, selected: true}}, contextual.rankedRecent)
972+
973+ normal := d.barStateFor(sess, "")
974+ require.Len(t, normal.mru, 1)
975+ require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
976+}
977+
938 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {978 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
939 p, releasePTY := newBlockingPTY(t)979 p, releasePTY := newBlockingPTY(t)
940 d, sess, _, _ := newManualSessionWithPTYs(t, p)980 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -999,7 +1039,7 @@ func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
999 state := barState{1039 state := barState{
1000 status: statusSnapshot{session: "cur"},1040 status: statusSnapshot{session: "cur"},
1001 attentionFrame: 1,1041 attentionFrame: 1,
1002- mru: []mruSession{1042+ mru: []recentSession{
1003 {name: "fresh"},1043 {name: "fresh"},
1004 {name: "tmp", ephemeral: true, attention: true},1044 {name: "tmp", ephemeral: true, attention: true},
1005 },1045 },
@@ -1031,10 +1071,44 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1031 require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)1071 require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
1032 }1072 }
1033 1073
1074+func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1075+ styles := resolveThemeStyles(nil)
1076+ row := make([]renderer.Cell, 18)
1077+ state := barState{
1078+ status: statusSnapshot{session: "cur"},
1079+ rankedRecent: []rankedRecent{
1080+ {rank: 1, name: "wide界"},
1081+ {rank: 2, name: "two", selected: true},
1082+ {rank: 3, name: "three"},
1083+ },
1084+ }
1085+
1086+ drawStatusBarState(row, state, styles)
1087+ text := rowText(row)
1088+ require.Contains(t, text, "1:wide界")
1089+ require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name.
1090+ require.NotContains(t, text, "3:three")
1091+ // A rank that does fit is accent-colored and its prefix is bold.
1092+ row = make([]renderer.Cell, 28)
1093+ drawStatusBarState(row, state, styles)
1094+ require.Contains(t, rowText(row), "2:two")
1095+ found := false
1096+ for _, cell := range row {
1097+ if cell.Rune == '2' {
1098+ found = true
1099+ require.True(t, cell.Style.Bold)
1100+ require.Equal(t, styles.accent.Foreground, cell.Style.Foreground)
1101+ require.Equal(t, styles.accent.Background, cell.Style.Background)
1102+ break
1103+ }
1104+ }
1105+ require.True(t, found)
1106+}
1107+
1034 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {1108 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1035 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}1109 theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1036 styles := newThemeStyles(theme)1110 styles := newThemeStyles(theme)
1037- state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}1111+ state := barState{status: statusSnapshot{session: "c"}, theme: theme, mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1038 row := make([]renderer.Cell, 16)1112 row := make([]renderer.Cell, 16)
1039 1113
1040 drawStatusBarState(row, state, styles)1114 drawStatusBarState(row, state, styles)
@@ -1059,7 +1133,7 @@ func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1059 }1133 }
1060 1134
1061 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {1135 func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1062- state := barState{status: statusSnapshot{session: "cur"}, mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}1136+ state := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1063 row := make([]renderer.Cell, 21)1137 row := make([]renderer.Cell, 21)
1064 1138
1065 drawStatusBarState(row, state, resolveThemeStyles(nil))1139 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1071,9 +1145,9 @@ func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1071 }1145 }
1072 1146
1073 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {1147 func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1074- mru := make([]mruSession, 0, maxMRUSessions)1148+ mru := make([]recentSession, 0, maxMRUSessions)
1075 for i := 1; i <= maxMRUSessions; i++ {1149 for i := 1; i <= maxMRUSessions; i++ {
1076- mru = append(mru, mruSession{name: "recent" + strconv.Itoa(i)})1150+ mru = append(mru, recentSession{name: "recent" + strconv.Itoa(i)})
1077 }1151 }
1078 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}1152 state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
1079 1153
@@ -1133,27 +1207,27 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1133 {1207 {
1134 name: "script text and copy feedback",1208 name: "script text and copy feedback",
1135 width: 32,1209 width: 32,
1136- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},1210+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "a"}}},
1137 wantContains: []string{" a"},1211 wantContains: []string{" a"},
1138 wantSuffix: " main ↑3 * copied",1212 wantSuffix: " main ↑3 * copied",
1139 },1213 },
1140 {1214 {
1141 name: "hide on overlap",1215 name: "hide on overlap",
1142 width: 16,1216 width: 16,
1143- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},1217+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
1144 want: " cur ",1218 want: " cur ",
1145 wantNotContains: []string{"main", "copied"},1219 wantNotContains: []string{"main", "copied"},
1146 },1220 },
1147 {1221 {
1148 name: "empty script keeps copy feedback behavior",1222 name: "empty script keeps copy feedback behavior",
1149 width: 12,1223 width: 12,
1150- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},1224+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}},
1151 want: " cur copied",1225 want: " cur copied",
1152 },1226 },
1153 {1227 {
1154 name: "mru whole entry fitting with script text",1228 name: "mru whole entry fitting with script text",
1155 width: 24,1229 width: 24,
1156- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},1230+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
1157 wantContains: []string{" fresh"},1231 wantContains: []string{" fresh"},
1158 wantNotContains: []string{"middle"},1232 wantNotContains: []string{"middle"},
1159 wantSuffix: " git",1233 wantSuffix: " git",
@@ -1161,7 +1235,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1161 {1235 {
1162 name: "mru fitting reserves wide right anchor width",1236 name: "mru fitting reserves wide right anchor width",
1163 width: 12,1237 width: 12,
1164- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},1238+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentSession{{name: "a"}}},
1165 wantNotContains: []string{" a "},1239 wantNotContains: []string{" a "},
1166 wantSuffix: " 界 界 ",1240 wantSuffix: " 界 界 ",
1167 continuationCells: []int{9, 11},1241 continuationCells: []int{9, 11},
@@ -1169,7 +1243,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1169 {1243 {
1170 name: "mru fitting counts wide session names",1244 name: "mru fitting counts wide session names",
1171 width: 20,1245 width: 20,
1172- state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},1246+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentSession{{name: "界"}, {name: "界"}, {name: "b"}}},
1173 wantContains: []string{" 界 "},1247 wantContains: []string{" 界 "},
1174 wantNotContains: []string{" b "},1248 wantNotContains: []string{" b "},
1175 wantSuffix: " git",1249 wantSuffix: " git",
@@ -1202,7 +1276,7 @@ func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1202 }1276 }
1203 1277
1204 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {1278 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1205- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}}1279+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "a"}, {name: "b"}, {name: "c"}}}
1206 row := make([]renderer.Cell, 20)1280 row := make([]renderer.Cell, 20)
1207 1281
1208 drawStatusBarState(row, state, resolveThemeStyles(nil))1282 drawStatusBarState(row, state, resolveThemeStyles(nil))
@@ -1215,7 +1289,7 @@ func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1215 }1289 }
1216 1290
1217 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {1291 func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1218- state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}1292+ state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []recentSession{{name: "fresh"}}}
1219 tests := []struct {1293 tests := []struct {
1220 name string1294 name string
1221 cols int1295 cols int
A internal/usecase/palette/action.go +57 -0
@@ -0,0 +1,57 @@
1+package palette
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// Action is the exact command token and its unmodified, whitespace-delimited
10+// arguments. It is deliberately independent of palette state.
11+type Action struct {
12+ Command command.Command
13+ Args []string
14+}
15+
16+// ParseAction recognizes an exact effective command token. It accepts outer
17+// and repeated whitespace but never treats a prefix or concatenated token as
18+// a command. Static commands reject arguments.
19+func ParseAction(commands []command.Command, input string) (Action, bool) {
20+ fields := strings.Fields(input)
21+ if len(fields) == 0 {
22+ return Action{}, false
23+ }
24+ for _, cmd := range commands {
25+ if !strings.EqualFold(fields[0], cmd.Code) {
26+ continue
27+ }
28+ args := append([]string(nil), fields[1:]...)
29+ switch cmd.Arguments {
30+ case command.ArgumentsNone:
31+ if len(args) != 0 {
32+ return Action{}, false
33+ }
34+ case command.ArgumentsRequired:
35+ if len(args) == 0 {
36+ return Action{}, false
37+ }
38+ }
39+ return Action{Command: cmd, Args: args}, true
40+ }
41+ return Action{}, false
42+}
43+
44+// ArgumentCommand returns the exact argument-taking command whose token is
45+// being typed, including the whitespace-before-argument state.
46+func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+ fields := strings.Fields(input)
48+ if len(fields) == 0 {
49+ return command.Command{}, false
50+ }
51+ for _, cmd := range commands {
52+ if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53+ return cmd, true
54+ }
55+ }
56+ return command.Command{}, false
57+}
@@ -0,0 +1,57 @@
1+package palette
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+// Action is the exact command token and its unmodified, whitespace-delimited
10+// arguments. It is deliberately independent of palette state.
11+type Action struct {
12+ Command command.Command
13+ Args []string
14+}
15+
16+// ParseAction recognizes an exact effective command token. It accepts outer
17+// and repeated whitespace but never treats a prefix or concatenated token as
18+// a command. Static commands reject arguments.
19+func ParseAction(commands []command.Command, input string) (Action, bool) {
20+ fields := strings.Fields(input)
21+ if len(fields) == 0 {
22+ return Action{}, false
23+ }
24+ for _, cmd := range commands {
25+ if !strings.EqualFold(fields[0], cmd.Code) {
26+ continue
27+ }
28+ args := append([]string(nil), fields[1:]...)
29+ switch cmd.Arguments {
30+ case command.ArgumentsNone:
31+ if len(args) != 0 {
32+ return Action{}, false
33+ }
34+ case command.ArgumentsRequired:
35+ if len(args) == 0 {
36+ return Action{}, false
37+ }
38+ }
39+ return Action{Command: cmd, Args: args}, true
40+ }
41+ return Action{}, false
42+}
43+
44+// ArgumentCommand returns the exact argument-taking command whose token is
45+// being typed, including the whitespace-before-argument state.
46+func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+ fields := strings.Fields(input)
48+ if len(fields) == 0 {
49+ return command.Command{}, false
50+ }
51+ for _, cmd := range commands {
52+ if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53+ return cmd, true
54+ }
55+ }
56+ return command.Command{}, false
57+}
A internal/usecase/palette/action_test.go +61 -0
@@ -0,0 +1,61 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+func TestParseActionExactEffectiveToken(t *testing.T) {
10+ commands := []command.Command{
11+ {Code: "BSK", Arguments: command.ArgumentsNone},
12+ {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13+ }
14+ tests := []struct {
15+ name string
16+ input string
17+ ok bool
18+ code string
19+ args []string
20+ }{
21+ {name: "static token ignores case and whitespace", input: " bSk ", ok: true, code: "BSK"},
22+ {name: "required arguments accept repeated whitespace", input: " JRS 2 ", ok: true, code: "JRS", args: []string{"2"}},
23+ {name: "partial token rejected", input: "JR", ok: false},
24+ {name: "concatenated token rejected", input: "JRS2", ok: false},
25+ {name: "static arguments rejected", input: "BSK 2", ok: false},
26+ {name: "missing required argument rejected", input: "JRS", ok: false},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ action, ok := ParseAction(commands, tt.input)
31+ if ok != tt.ok {
32+ t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33+ }
34+ if !ok {
35+ return
36+ }
37+ if action.Command.Code != tt.code || !sameStrings(action.Args, tt.args) {
38+ t.Fatalf("ParseAction(%q) = %#v, want %s %#v", tt.input, action, tt.code, tt.args)
39+ }
40+ })
41+ }
42+}
43+
44+func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45+ cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46+ if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
47+ t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48+ }
49+}
50+
51+func sameStrings(a, b []string) bool {
52+ if len(a) != len(b) {
53+ return false
54+ }
55+ for i := range a {
56+ if a[i] != b[i] {
57+ return false
58+ }
59+ }
60+ return true
61+}
@@ -0,0 +1,61 @@
1+package palette
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/usecase/command"
7+)
8+
9+func TestParseActionExactEffectiveToken(t *testing.T) {
10+ commands := []command.Command{
11+ {Code: "BSK", Arguments: command.ArgumentsNone},
12+ {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13+ }
14+ tests := []struct {
15+ name string
16+ input string
17+ ok bool
18+ code string
19+ args []string
20+ }{
21+ {name: "static token ignores case and whitespace", input: " bSk ", ok: true, code: "BSK"},
22+ {name: "required arguments accept repeated whitespace", input: " JRS 2 ", ok: true, code: "JRS", args: []string{"2"}},
23+ {name: "partial token rejected", input: "JR", ok: false},
24+ {name: "concatenated token rejected", input: "JRS2", ok: false},
25+ {name: "static arguments rejected", input: "BSK 2", ok: false},
26+ {name: "missing required argument rejected", input: "JRS", ok: false},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ action, ok := ParseAction(commands, tt.input)
31+ if ok != tt.ok {
32+ t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33+ }
34+ if !ok {
35+ return
36+ }
37+ if action.Command.Code != tt.code || !sameStrings(action.Args, tt.args) {
38+ t.Fatalf("ParseAction(%q) = %#v, want %s %#v", tt.input, action, tt.code, tt.args)
39+ }
40+ })
41+ }
42+}
43+
44+func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45+ cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46+ if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
47+ t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48+ }
49+}
50+
51+func sameStrings(a, b []string) bool {
52+ if len(a) != len(b) {
53+ return false
54+ }
55+ for i := range a {
56+ if a[i] != b[i] {
57+ return false
58+ }
59+ }
60+ return true
61+}
A internal/usecase/palette/contextual_hints.go +46 -0
@@ -0,0 +1,46 @@
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+ SelectedRank int
20+ Feedback string
21+}
22+
23+// BuildRecentSessionHints produces guidance without consulting live daemon state.
24+func BuildRecentSessionHints(names []string, args []string) ContextualHints {
25+ h := ContextualHints{Kind: command.ContextHintRecentSessions}
26+ h.Recent = make([]RecentSessionHint, len(names))
27+ for i, name := range names {
28+ h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name}
29+ }
30+ if len(names) == 0 {
31+ h.Feedback = "no recent sessions"
32+ return h
33+ }
34+ rank, err := command.ParsePositiveDecimal(args)
35+ if len(args) == 0 {
36+ h.Feedback = "enter a recent-session rank"
37+ } else if err != nil {
38+ h.Feedback = "rank must be one positive decimal"
39+ } else if rank > len(names) {
40+ h.Feedback = fmt.Sprintf("rank %d is unavailable", rank)
41+ } else {
42+ h.SelectedRank = rank
43+ h.Feedback = fmt.Sprintf("jump to recent session %d", rank)
44+ }
45+ return h
46+}
@@ -0,0 +1,46 @@
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+ SelectedRank int
20+ Feedback string
21+}
22+
23+// BuildRecentSessionHints produces guidance without consulting live daemon state.
24+func BuildRecentSessionHints(names []string, args []string) ContextualHints {
25+ h := ContextualHints{Kind: command.ContextHintRecentSessions}
26+ h.Recent = make([]RecentSessionHint, len(names))
27+ for i, name := range names {
28+ h.Recent[i] = RecentSessionHint{Rank: i + 1, Name: name}
29+ }
30+ if len(names) == 0 {
31+ h.Feedback = "no recent sessions"
32+ return h
33+ }
34+ rank, err := command.ParsePositiveDecimal(args)
35+ if len(args) == 0 {
36+ h.Feedback = "enter a recent-session rank"
37+ } else if err != nil {
38+ h.Feedback = "rank must be one positive decimal"
39+ } else if rank > len(names) {
40+ h.Feedback = fmt.Sprintf("rank %d is unavailable", rank)
41+ } else {
42+ h.SelectedRank = rank
43+ h.Feedback = fmt.Sprintf("jump to recent session %d", rank)
44+ }
45+ return h
46+}
M internal/usecase/palette/model.go +49 -7
@@ -12,6 +12,14 @@ type RenderStyles struct {
1212 Description renderer.Style
1313 }
1414
15+// RenderOptions contains presentation supplied by the daemon for one render.
16+// Guidance is substituted only into the exact contextual command row; it never
17+// adds rows or changes fuzzy matching geometry.
18+type RenderOptions struct {
19+ Styles RenderStyles
20+ Guidance string
21+}
22+
1523 type Model struct {
1624 commands []command.Command
1725 input ui.TextInput
@@ -52,6 +60,7 @@ func (m *Model) Backspace() {
5260 m.refresh()
5361 }
5462 }
63+
5564 func (m *Model) Query() string {
5665 if m == nil {
5766 return ""
@@ -76,6 +85,15 @@ func (m *Model) Selected() (command.Command, bool) {
7685 }
7786 return m.matches[m.selected].Command, true
7887 }
88+
89+// ArgumentCommand returns the exact argument-taking command being entered.
90+func (m *Model) ArgumentCommand() (command.Command, bool) {
91+ if m == nil {
92+ return command.Command{}, false
93+ }
94+ return ArgumentCommand(m.commands, m.Query())
95+}
96+
7997 func (m *Model) Matches() []Match {
8098 if m == nil {
8199 return nil
@@ -88,7 +106,24 @@ func (m *Model) Matches() []Match {
88106 return out
89107 }
90108
91-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }
109+func (m *Model) refresh() {
110+ m.matches = Fuzzy(m.commands, m.input.Value())
111+ // Once an argument-taking token is exact, retain its row while its
112+ // arguments make ordinary fuzzy matching inapplicable.
113+ if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
114+ found := false
115+ for _, match := range m.matches {
116+ if match.Command.Code == cmd.Code {
117+ found = true
118+ break
119+ }
120+ }
121+ if !found {
122+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
123+ }
124+ }
125+ m.clamp()
126+}
92127 func (m *Model) clamp() {
93128 if len(m.matches) == 0 {
94129 m.selected = -1
@@ -109,7 +144,8 @@ func (m *Model) clamp() {
109144 }
110145 }
111146
112-func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
147+func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
148+ styles := opts.Styles
113149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
114150 if frame.Width == 0 || frame.Height == 0 {
115151 return frame
@@ -123,7 +159,8 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
123159 return frame
124160 }
125161 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
126- visible := frame.Height - 1
162+ start := 1
163+ visible := frame.Height - start
127164 if visible <= 0 {
128165 return frame
129166 }
@@ -142,6 +179,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
142179 codeWidth = len([]rune(match.Command.Code))
143180 }
144181 }
182+ activeCmd, activeOK := m.ArgumentCommand()
145183 for y := range visible {
146184 idx := m.scroll + y
147185 if idx >= len(m.matches) {
@@ -152,7 +190,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
152190 if idx == m.selected {
153191 style = selection
154192 }
155- ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
193+ ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
156194 x := 0
157195 highlight := map[int]bool{}
158196 for _, p := range match.Positions {
@@ -166,15 +204,19 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
166204 cellStyle.Bold = true
167205 }
168206 if x < frame.Width {
169- frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})
207+ frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
170208 }
171209 x++
172210 }
173211 for x < codeWidth+1 && x < frame.Width {
174- frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
212+ frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})
175213 x++
176214 }
177- ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
215+ description := match.Command.Desc
216+ if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {
217+ description = opts.Guidance
218+ }
219+ ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))
178220 }
179221 return frame
180222 }
@@ -12,6 +12,14 @@ type RenderStyles struct {
12 Description renderer.Style12 Description renderer.Style
13 }13 }
14 14
15+// RenderOptions contains presentation supplied by the daemon for one render.
16+// Guidance is substituted only into the exact contextual command row; it never
17+// adds rows or changes fuzzy matching geometry.
18+type RenderOptions struct {
19+ Styles RenderStyles
20+ Guidance string
21+}
22+
15 type Model struct {23 type Model struct {
16 commands []command.Command24 commands []command.Command
17 input ui.TextInput25 input ui.TextInput
@@ -52,6 +60,7 @@ func (m *Model) Backspace() {
52 m.refresh()60 m.refresh()
53 }61 }
54 }62 }
63+
55 func (m *Model) Query() string {64 func (m *Model) Query() string {
56 if m == nil {65 if m == nil {
57 return ""66 return ""
@@ -76,6 +85,15 @@ func (m *Model) Selected() (command.Command, bool) {
76 }85 }
77 return m.matches[m.selected].Command, true86 return m.matches[m.selected].Command, true
78 }87 }
88+
89+// ArgumentCommand returns the exact argument-taking command being entered.
90+func (m *Model) ArgumentCommand() (command.Command, bool) {
91+ if m == nil {
92+ return command.Command{}, false
93+ }
94+ return ArgumentCommand(m.commands, m.Query())
95+}
96+
79 func (m *Model) Matches() []Match {97 func (m *Model) Matches() []Match {
80 if m == nil {98 if m == nil {
81 return nil99 return nil
@@ -88,7 +106,24 @@ func (m *Model) Matches() []Match {
88 return out106 return out
89 }107 }
90 108
91-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }109+func (m *Model) refresh() {
110+ m.matches = Fuzzy(m.commands, m.input.Value())
111+ // Once an argument-taking token is exact, retain its row while its
112+ // arguments make ordinary fuzzy matching inapplicable.
113+ if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
114+ found := false
115+ for _, match := range m.matches {
116+ if match.Command.Code == cmd.Code {
117+ found = true
118+ break
119+ }
120+ }
121+ if !found {
122+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
123+ }
124+ }
125+ m.clamp()
126+}
92 func (m *Model) clamp() {127 func (m *Model) clamp() {
93 if len(m.matches) == 0 {128 if len(m.matches) == 0 {
94 m.selected = -1129 m.selected = -1
@@ -109,7 +144,8 @@ func (m *Model) clamp() {
109 }144 }
110 }145 }
111 146
112-func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {147+func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
148+ styles := opts.Styles
113 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
114 if frame.Width == 0 || frame.Height == 0 {150 if frame.Width == 0 || frame.Height == 0 {
115 return frame151 return frame
@@ -123,7 +159,8 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
123 return frame159 return frame
124 }160 }
125 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)161 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
126- visible := frame.Height - 1162+ start := 1
163+ visible := frame.Height - start
127 if visible <= 0 {164 if visible <= 0 {
128 return frame165 return frame
129 }166 }
@@ -142,6 +179,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
142 codeWidth = len([]rune(match.Command.Code))179 codeWidth = len([]rune(match.Command.Code))
143 }180 }
144 }181 }
182+ activeCmd, activeOK := m.ArgumentCommand()
145 for y := range visible {183 for y := range visible {
146 idx := m.scroll + y184 idx := m.scroll + y
147 if idx >= len(m.matches) {185 if idx >= len(m.matches) {
@@ -152,7 +190,7 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
152 if idx == m.selected {190 if idx == m.selected {
153 style = selection191 style = selection
154 }192 }
155- ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})193+ ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
156 x := 0194 x := 0
157 highlight := map[int]bool{}195 highlight := map[int]bool{}
158 for _, p := range match.Positions {196 for _, p := range match.Positions {
@@ -166,15 +204,19 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
166 cellStyle.Bold = true204 cellStyle.Bold = true
167 }205 }
168 if x < frame.Width {206 if x < frame.Width {
169- frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})207+ frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
170 }208 }
171 x++209 x++
172 }210 }
173 for x < codeWidth+1 && x < frame.Width {211 for x < codeWidth+1 && x < frame.Width {
174- frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})212+ frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})
175 x++213 x++
176 }214 }
177- ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))215+ description := match.Command.Desc
216+ if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {
217+ description = opts.Guidance
218+ }
219+ ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))
178 }220 }
179 return frame221 return frame
180 }222 }
M internal/usecase/palette/model_test.go +18 -3
@@ -74,7 +74,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
7474 })
7575 m.Insert('c')
7676 m.Insert('y')
77- frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles())
77+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
7878
7979 require.Equal(t, '>', frame.At(0, 0).Rune)
8080 require.Equal(t, ' ', frame.At(1, 0).Rune)
@@ -99,7 +99,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
9999
100100 for _, cols := range []int{0, 1, 3, 4, 7} {
101101 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102- frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())
102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
103103 require.Equal(t, cols, frame.Width)
104104 if cols > 0 {
105105 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
@@ -179,9 +179,24 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
179179 m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
180180 m.Insert('c')
181181 m.Insert('y')
182- frame := m.Render(domain.Size{Cols: 28, Rows: 3}, tt.styles())
182+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
183183
184184 tt.assert(t, frame.At(tt.x, 1).Style)
185185 })
186186 }
187187 }
188+
189+func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190+ jrs := cmd("JRS", "Jump", "Jump to recent session")
191+ jrs.Arguments = command.ArgumentsRequired
192+ jrs.ContextHint = command.ContextHintRecentSessions
193+ m := New([]command.Command{jrs})
194+ for _, r := range "JRS 1" {
195+ m.Insert(r)
196+ }
197+
198+ frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
199+ if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
200+ t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
201+ }
202+}
@@ -74,7 +74,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
74 })74 })
75 m.Insert('c')75 m.Insert('c')
76 m.Insert('y')76 m.Insert('y')
77- frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles())77+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
78 78
79 require.Equal(t, '>', frame.At(0, 0).Rune)79 require.Equal(t, '>', frame.At(0, 0).Rune)
80 require.Equal(t, ' ', frame.At(1, 0).Rune)80 require.Equal(t, ' ', frame.At(1, 0).Rune)
@@ -99,7 +99,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
99 99
100 for _, cols := range []int{0, 1, 3, 4, 7} {100 for _, cols := range []int{0, 1, 3, 4, 7} {
101 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {101 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102- frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
103 require.Equal(t, cols, frame.Width)103 require.Equal(t, cols, frame.Width)
104 if cols > 0 {104 if cols > 0 {
105 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))105 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
@@ -179,9 +179,24 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
179 m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})179 m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
180 m.Insert('c')180 m.Insert('c')
181 m.Insert('y')181 m.Insert('y')
182- frame := m.Render(domain.Size{Cols: 28, Rows: 3}, tt.styles())182+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
183 183
184 tt.assert(t, frame.At(tt.x, 1).Style)184 tt.assert(t, frame.At(tt.x, 1).Style)
185 })185 })
186 }186 }
187 }187 }
188+
189+func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190+ jrs := cmd("JRS", "Jump", "Jump to recent session")
191+ jrs.Arguments = command.ArgumentsRequired
192+ jrs.ContextHint = command.ContextHintRecentSessions
193+ m := New([]command.Command{jrs})
194+ for _, r := range "JRS 1" {
195+ m.Insert(r)
196+ }
197+
198+ frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
199+ if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
200+ t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
201+ }
202+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close