Hide unjail command unless jailed
- commit
- 528ecf2db3808a8acbbf71eacd5833be9ceba0da
- author
- patriceckhart · · 2026-05-07 16:41 UTC
- parent
- 380eca9
- signature
- unsigned
- refs
- tag: v0.1.63
3 files · +65 -4
M internal/agent/modes/interactive.go +1 -0
| @@ -912,6 +912,7 @@ func (i *Interactive) redraw() { | |||
| 912 | 912 | // when the editor starts with "/" and no dialog is already open. | |
| 913 | 913 | // Feed extension-registered commands into the suggester first so | |
| 914 | 914 | // they show up in tab-complete + the popup alongside the built-ins. | |
| 915 | + | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | |
| 915 | 916 | if i.cfg.Extensions != nil { | |
| 916 | 917 | catalog := i.cfg.Extensions.Commands() | |
| 917 | 918 | extra := make([]slashCommand, 0, len(catalog)) | |
| @@ -912,6 +912,7 @@ func (i *Interactive) redraw() { | |||||
| 912 | // when the editor starts with "/" and no dialog is already open. | 912 | // when the editor starts with "/" and no dialog is already open. | ||
| 913 | // Feed extension-registered commands into the suggester first so | 913 | // Feed extension-registered commands into the suggester first so | ||
| 914 | // they show up in tab-complete + the popup alongside the built-ins. | 914 | // they show up in tab-complete + the popup alongside the built-ins. | ||
| 915 | + | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | |||
| 915 | if i.cfg.Extensions != nil { | 916 | if i.cfg.Extensions != nil { | ||
| 916 | catalog := i.cfg.Extensions.Commands() | 917 | catalog := i.cfg.Extensions.Commands() | ||
| 917 | extra := make([]slashCommand, 0, len(catalog)) | 918 | extra := make([]slashCommand, 0, len(catalog)) | ||
M internal/agent/modes/slash_suggest.go +29 -4
| @@ -57,6 +57,10 @@ var slashCatalog = []slashCommand{ | |||
| 57 | 57 | type slashSuggester struct { | |
| 58 | 58 | cursor int | |
| 59 | 59 | ||
| 60 | + | // jailed tracks whether the sandbox is currently locked. It is used | |
| 61 | + | // to hide state-dependent commands from the autocomplete popup. | |
| 62 | + | jailed bool | |
| 63 | + | ||
| 60 | 64 | // extra are commands contributed by extensions, refreshed each | |
| 61 | 65 | // frame from the extension manager. Empty when no extensions | |
| 62 | 66 | // have registered any. Sorted by name in SetExtra so map | |
| @@ -80,20 +84,25 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |||
| 80 | 84 | s.extra = sorted | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 87 | + | // SetJailed updates the current sandbox state. Called once per render | |
| 88 | + | // so state-dependent commands can appear/disappear immediately. | |
| 89 | + | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |
| 90 | + | ||
| 83 | 91 | // allCatalog returns slashCatalog plus the current extra commands | |
| 84 | 92 | // (extension-registered) with a header divider between the two | |
| 85 | 93 | // groups. Extra entries are only kept if they don't collide with | |
| 86 | 94 | // a built-in name; the built-in always wins. | |
| 87 | 95 | func (s *slashSuggester) allCatalog() []slashCommand { | |
| 96 | + | base := s.baseCatalog() | |
| 88 | 97 | if len(s.extra) == 0 { | |
| 89 | - | return slashCatalog | |
| 98 | + | return base | |
| 90 | 99 | } | |
| 91 | - | out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)+1) | |
| 92 | - | out = append(out, slashCatalog...) | |
| 100 | + | out := make([]slashCommand, 0, len(base)+len(s.extra)+1) | |
| 101 | + | out = append(out, base...) | |
| 93 | 102 | var kept []slashCommand | |
| 94 | 103 | for _, c := range s.extra { | |
| 95 | 104 | dup := false | |
| 96 | - | for _, b := range slashCatalog { | |
| 105 | + | for _, b := range base { | |
| 97 | 106 | if b.Name == c.Name { | |
| 98 | 107 | dup = true | |
| 99 | 108 | break | |
| @@ -110,6 +119,22 @@ func (s *slashSuggester) allCatalog() []slashCommand { | |||
| 110 | 119 | return out | |
| 111 | 120 | } | |
| 112 | 121 | ||
| 122 | + | // baseCatalog returns the built-in commands visible for the current | |
| 123 | + | // interactive state. | |
| 124 | + | func (s *slashSuggester) baseCatalog() []slashCommand { | |
| 125 | + | if s.jailed { | |
| 126 | + | return slashCatalog | |
| 127 | + | } | |
| 128 | + | out := make([]slashCommand, 0, len(slashCatalog)-1) | |
| 129 | + | for _, c := range slashCatalog { | |
| 130 | + | if c.Name == "/unjail" { | |
| 131 | + | continue | |
| 132 | + | } | |
| 133 | + | out = append(out, c) | |
| 134 | + | } | |
| 135 | + | return out | |
| 136 | + | } | |
| 137 | + | ||
| 113 | 138 | // looksLikeSlashCommand reports whether text is an attempt at a slash | |
| 114 | 139 | // command (valid or not). Returns true for things like "/foo" or | |
| 115 | 140 | // "/bar baz" but false for paths ("/Users/pat/...") and regexes | |
| @@ -57,6 +57,10 @@ var slashCatalog = []slashCommand{ | |||||
| 57 | type slashSuggester struct { | 57 | type slashSuggester struct { | ||
| 58 | cursor int | 58 | cursor int | ||
| 59 | 59 | ||||
| 60 | + | // jailed tracks whether the sandbox is currently locked. It is used | |||
| 61 | + | // to hide state-dependent commands from the autocomplete popup. | |||
| 62 | + | jailed bool | |||
| 63 | + | ||||
| 60 | // extra are commands contributed by extensions, refreshed each | 64 | // extra are commands contributed by extensions, refreshed each | ||
| 61 | // frame from the extension manager. Empty when no extensions | 65 | // frame from the extension manager. Empty when no extensions | ||
| 62 | // have registered any. Sorted by name in SetExtra so map | 66 | // have registered any. Sorted by name in SetExtra so map | ||
| @@ -80,20 +84,25 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |||||
| 80 | s.extra = sorted | 84 | s.extra = sorted | ||
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
| 87 | + | // SetJailed updates the current sandbox state. Called once per render | |||
| 88 | + | // so state-dependent commands can appear/disappear immediately. | |||
| 89 | + | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |||
| 90 | + | ||||
| 83 | // allCatalog returns slashCatalog plus the current extra commands | 91 | // allCatalog returns slashCatalog plus the current extra commands | ||
| 84 | // (extension-registered) with a header divider between the two | 92 | // (extension-registered) with a header divider between the two | ||
| 85 | // groups. Extra entries are only kept if they don't collide with | 93 | // groups. Extra entries are only kept if they don't collide with | ||
| 86 | // a built-in name; the built-in always wins. | 94 | // a built-in name; the built-in always wins. | ||
| 87 | func (s *slashSuggester) allCatalog() []slashCommand { | 95 | func (s *slashSuggester) allCatalog() []slashCommand { | ||
| 96 | + | base := s.baseCatalog() | |||
| 88 | if len(s.extra) == 0 { | 97 | if len(s.extra) == 0 { | ||
| 89 | - | return slashCatalog | 98 | + | return base |
| 90 | } | 99 | } | ||
| 91 | - | out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)+1) | 100 | + | out := make([]slashCommand, 0, len(base)+len(s.extra)+1) |
| 92 | - | out = append(out, slashCatalog...) | 101 | + | out = append(out, base...) |
| 93 | var kept []slashCommand | 102 | var kept []slashCommand | ||
| 94 | for _, c := range s.extra { | 103 | for _, c := range s.extra { | ||
| 95 | dup := false | 104 | dup := false | ||
| 96 | - | for _, b := range slashCatalog { | 105 | + | for _, b := range base { |
| 97 | if b.Name == c.Name { | 106 | if b.Name == c.Name { | ||
| 98 | dup = true | 107 | dup = true | ||
| 99 | break | 108 | break | ||
| @@ -110,6 +119,22 @@ func (s *slashSuggester) allCatalog() []slashCommand { | |||||
| 110 | return out | 119 | return out | ||
| 111 | } | 120 | } | ||
| 112 | 121 | ||||
| 122 | + | // baseCatalog returns the built-in commands visible for the current | |||
| 123 | + | // interactive state. | |||
| 124 | + | func (s *slashSuggester) baseCatalog() []slashCommand { | |||
| 125 | + | if s.jailed { | |||
| 126 | + | return slashCatalog | |||
| 127 | + | } | |||
| 128 | + | out := make([]slashCommand, 0, len(slashCatalog)-1) | |||
| 129 | + | for _, c := range slashCatalog { | |||
| 130 | + | if c.Name == "/unjail" { | |||
| 131 | + | continue | |||
| 132 | + | } | |||
| 133 | + | out = append(out, c) | |||
| 134 | + | } | |||
| 135 | + | return out | |||
| 136 | + | } | |||
| 137 | + | ||||
| 113 | // looksLikeSlashCommand reports whether text is an attempt at a slash | 138 | // looksLikeSlashCommand reports whether text is an attempt at a slash | ||
| 114 | // command (valid or not). Returns true for things like "/foo" or | 139 | // command (valid or not). Returns true for things like "/foo" or | ||
| 115 | // "/bar baz" but false for paths ("/Users/pat/...") and regexes | 140 | // "/bar baz" but false for paths ("/Users/pat/...") and regexes | ||
A internal/agent/modes/slash_suggest_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |
| 6 | + | s := newSlashSuggester() | |
| 7 | + | ||
| 8 | + | if got := commandNames(s.matches("/unj")); contains(got, "/unjail") { | |
| 9 | + | t.Fatalf("/unjail should be hidden while not jailed, got %v", got) | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | s.SetJailed(true) | |
| 13 | + | if got := commandNames(s.matches("/unj")); !contains(got, "/unjail") { | |
| 14 | + | t.Fatalf("/unjail should be visible while jailed, got %v", got) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func commandNames(cmds []slashCommand) []string { | |
| 19 | + | out := make([]string, 0, len(cmds)) | |
| 20 | + | for _, c := range cmds { | |
| 21 | + | if !c.Header { | |
| 22 | + | out = append(out, c.Name) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | return out | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func contains(xs []string, want string) bool { | |
| 29 | + | for _, x := range xs { | |
| 30 | + | if x == want { | |
| 31 | + | return true | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | return false | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |||
| 6 | + | s := newSlashSuggester() | |||
| 7 | + | ||||
| 8 | + | if got := commandNames(s.matches("/unj")); contains(got, "/unjail") { | |||
| 9 | + | t.Fatalf("/unjail should be hidden while not jailed, got %v", got) | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | s.SetJailed(true) | |||
| 13 | + | if got := commandNames(s.matches("/unj")); !contains(got, "/unjail") { | |||
| 14 | + | t.Fatalf("/unjail should be visible while jailed, got %v", got) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func commandNames(cmds []slashCommand) []string { | |||
| 19 | + | out := make([]string, 0, len(cmds)) | |||
| 20 | + | for _, c := range cmds { | |||
| 21 | + | if !c.Header { | |||
| 22 | + | out = append(out, c.Name) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | return out | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func contains(xs []string, want string) bool { | |||
| 29 | + | for _, x := range xs { | |||
| 30 | + | if x == want { | |||
| 31 | + | return true | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | return false | |||
| 35 | + | } | |||