diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index bd87b02..d1758f2 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -912,6 +912,7 @@ func (i *Interactive) redraw() { // when the editor starts with "/" and no dialog is already open. // Feed extension-registered commands into the suggester first so // they show up in tab-complete + the popup alongside the built-ins. + i.suggest.SetJailed(i.cfg.Sandbox.Locked()) if i.cfg.Extensions != nil { catalog := i.cfg.Extensions.Commands() extra := make([]slashCommand, 0, len(catalog)) diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go index c966b28..2228fc8 100644 --- a/internal/agent/modes/slash_suggest.go +++ b/internal/agent/modes/slash_suggest.go @@ -57,6 +57,10 @@ var slashCatalog = []slashCommand{ type slashSuggester struct { cursor int + // jailed tracks whether the sandbox is currently locked. It is used + // to hide state-dependent commands from the autocomplete popup. + jailed bool + // extra are commands contributed by extensions, refreshed each // frame from the extension manager. Empty when no extensions // have registered any. Sorted by name in SetExtra so map @@ -80,20 +84,25 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = sorted } +// SetJailed updates the current sandbox state. Called once per render +// so state-dependent commands can appear/disappear immediately. +func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } + // allCatalog returns slashCatalog plus the current extra commands // (extension-registered) with a header divider between the two // groups. Extra entries are only kept if they don't collide with // a built-in name; the built-in always wins. func (s *slashSuggester) allCatalog() []slashCommand { + base := s.baseCatalog() if len(s.extra) == 0 { - return slashCatalog + return base } - out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)+1) - out = append(out, slashCatalog...) + out := make([]slashCommand, 0, len(base)+len(s.extra)+1) + out = append(out, base...) var kept []slashCommand for _, c := range s.extra { dup := false - for _, b := range slashCatalog { + for _, b := range base { if b.Name == c.Name { dup = true break @@ -110,6 +119,22 @@ func (s *slashSuggester) allCatalog() []slashCommand { return out } +// baseCatalog returns the built-in commands visible for the current +// interactive state. +func (s *slashSuggester) baseCatalog() []slashCommand { + if s.jailed { + return slashCatalog + } + out := make([]slashCommand, 0, len(slashCatalog)-1) + for _, c := range slashCatalog { + if c.Name == "/unjail" { + continue + } + out = append(out, c) + } + return out +} + // looksLikeSlashCommand reports whether text is an attempt at a slash // command (valid or not). Returns true for things like "/foo" or // "/bar baz" but false for paths ("/Users/pat/...") and regexes diff --git a/internal/agent/modes/slash_suggest_test.go b/internal/agent/modes/slash_suggest_test.go new file mode 100644 index 0000000..e7292fe --- /dev/null +++ b/internal/agent/modes/slash_suggest_test.go @@ -0,0 +1,35 @@ +package modes + +import "testing" + +func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { + s := newSlashSuggester() + + if got := commandNames(s.matches("/unj")); contains(got, "/unjail") { + t.Fatalf("/unjail should be hidden while not jailed, got %v", got) + } + + s.SetJailed(true) + if got := commandNames(s.matches("/unj")); !contains(got, "/unjail") { + t.Fatalf("/unjail should be visible while jailed, got %v", got) + } +} + +func commandNames(cmds []slashCommand) []string { + out := make([]string, 0, len(cmds)) + for _, c := range cmds { + if !c.Header { + out = append(out, c.Name) + } + } + return out +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +}