6c5e969217
Mirrors the pattern of /permission: bare command shows the current value plus a help line; with an argument (auto/local/cloud) it calls Router.SetPreferPolicy and emits a system message. Session-only — does not write back to config.toml, matching /permission and Ctrl+X incognito-toggle conventions. Tab completion on the value via routerPreferModes alongside the existing permissionModes pattern. Help text updated. Status-bar indicator deferred (separate concern if it turns out to be wanted).
206 lines
6.2 KiB
Go
206 lines
6.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/skill"
|
|
)
|
|
|
|
// cmdEntry is a slash command with a short description.
|
|
type cmdEntry struct {
|
|
name string
|
|
desc string
|
|
}
|
|
|
|
// builtinCommands is the static list of slash commands with descriptions.
|
|
var builtinCommands = []cmdEntry{
|
|
{"/clear", "clear conversation history"},
|
|
{"/compact", "summarize and compact conversation context"},
|
|
{"/config", "open settings panel"},
|
|
{"/copy", "copy the latest assistant response to the clipboard"},
|
|
{"/exit", "exit gnoma"},
|
|
{"/help", "show available commands and shortcuts"},
|
|
{"/incognito", "toggle incognito mode (no persistence, local-only routing)"},
|
|
// /init is provided by the bundled skill at
|
|
// internal/skill/skills/init.md; do not duplicate it here. The dedup
|
|
// in completionSource() would skip a duplicate entry anyway, but
|
|
// omitting it keeps the source-of-truth single.
|
|
{"/keys", "show keyboard shortcuts"},
|
|
{"/model", "list or switch active model"},
|
|
{"/new", "start a new conversation"},
|
|
{"/perm", "show or set permission mode"},
|
|
{"/permission", "show or set permission mode"},
|
|
{"/plugins", "list installed plugins"},
|
|
{"/profile", "list profiles or switch to one (re-execs gnoma)"},
|
|
{"/provider", "list or switch provider"},
|
|
{"/quit", "quit gnoma"},
|
|
{"/replay", "replay last assistant response"},
|
|
{"/resume", "browse and resume a saved session"},
|
|
{"/router", "show or set routing preference (auto/local/cloud)"},
|
|
{"/shell", "open interactive shell"},
|
|
{"/theme", "list themes or set active theme"},
|
|
{"/skills", "list available skills"},
|
|
{"/usage", "show token usage for this session"},
|
|
{"/vim", "toggle Vim keybindings in the input composer"},
|
|
}
|
|
|
|
// permissionModes lists valid modes for /permission completion.
|
|
var permissionModes = []string{
|
|
"auto", "default", "accept_edits", "bypass", "deny", "plan",
|
|
}
|
|
|
|
// routerPreferModes lists valid values for /router completion.
|
|
var routerPreferModes = []string{"auto", "local", "cloud"}
|
|
|
|
// completionSource builds a sorted command list from builtins + skills.
|
|
// Skill names shadow builtin names so a skill (bundled or user-defined)
|
|
// can replace a static entry without producing a duplicate in the picker.
|
|
func completionSource(skills *skill.Registry) []cmdEntry {
|
|
skillNames := make(map[string]struct{})
|
|
if skills != nil {
|
|
for _, s := range skills.All() {
|
|
skillNames["/"+s.Frontmatter.Name] = struct{}{}
|
|
}
|
|
}
|
|
|
|
entries := make([]cmdEntry, 0, len(builtinCommands)+len(skillNames))
|
|
for _, c := range builtinCommands {
|
|
if _, shadowed := skillNames[c.name]; shadowed {
|
|
continue
|
|
}
|
|
entries = append(entries, c)
|
|
}
|
|
if skills != nil {
|
|
for _, s := range skills.All() {
|
|
desc := s.Frontmatter.Description
|
|
if desc == "" {
|
|
desc = "skill"
|
|
}
|
|
entries = append(entries, cmdEntry{"/" + s.Frontmatter.Name, desc})
|
|
}
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].name < entries[j].name
|
|
})
|
|
return entries
|
|
}
|
|
|
|
// matchSuggestions returns all commands whose name has the given prefix.
|
|
// Returns nil if input is empty, doesn't start with '/', or contains a space.
|
|
func matchSuggestions(input string, commands []cmdEntry) []cmdEntry {
|
|
if !strings.HasPrefix(input, "/") || len(input) < 2 || strings.Contains(input, " ") {
|
|
return nil
|
|
}
|
|
lower := strings.ToLower(input)
|
|
var matches []cmdEntry
|
|
for _, c := range commands {
|
|
if strings.HasPrefix(c.name, lower) {
|
|
matches = append(matches, c)
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// matchCompletion returns the unique ghost-text completion, or "".
|
|
// Used for Tab acceptance of a single unambiguous match. profileNames
|
|
// is the dynamic completion source for `/profile <name>`, and providerNames
|
|
// is for `/provider <name>` — pass nil when none are known.
|
|
func matchCompletion(input string, commands []cmdEntry, profileNames []string, providerNames []string) string {
|
|
if !strings.HasPrefix(input, "/") || len(input) < 2 {
|
|
return ""
|
|
}
|
|
if strings.Contains(input, " ") {
|
|
return matchArgCompletion(input, profileNames, providerNames)
|
|
}
|
|
suggestions := matchSuggestions(input, commands)
|
|
if len(suggestions) == 1 && suggestions[0].name != input {
|
|
return suggestions[0].name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fuzzyMatch returns true if every rune in pattern appears in text in order.
|
|
func fuzzyMatch(pattern, text string) bool {
|
|
text = strings.ToLower(text)
|
|
pattern = strings.ToLower(pattern)
|
|
pi := 0
|
|
for _, ch := range text {
|
|
if pi < len(pattern) && rune(pattern[pi]) == ch {
|
|
pi++
|
|
}
|
|
}
|
|
return pi == len(pattern)
|
|
}
|
|
|
|
// fuzzyMatchCommands filters commands whose name (without leading "/") fuzzy-matches query.
|
|
func fuzzyMatchCommands(query string, commands []cmdEntry) []cmdEntry {
|
|
if query == "" {
|
|
return commands
|
|
}
|
|
var matches []cmdEntry
|
|
for _, c := range commands {
|
|
name := strings.TrimPrefix(c.name, "/")
|
|
if fuzzyMatch(query, name) {
|
|
matches = append(matches, c)
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// matchArgCompletion handles second-level completion for commands with args.
|
|
// profileNames is the dynamic source for `/profile <name>`, and providerNames
|
|
// is for `/provider <name>`; pass nil when not available.
|
|
func matchArgCompletion(input string, profileNames []string, providerNames []string) string {
|
|
parts := strings.SplitN(input, " ", 2)
|
|
if len(parts) != 2 {
|
|
return ""
|
|
}
|
|
cmd := parts[0]
|
|
arg := parts[1]
|
|
|
|
switch cmd {
|
|
case "/permission", "/perm":
|
|
if arg == "" {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(arg)
|
|
for _, mode := range permissionModes {
|
|
if strings.HasPrefix(mode, lower) && mode != arg {
|
|
return cmd + " " + mode
|
|
}
|
|
}
|
|
case "/router":
|
|
if arg == "" {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(arg)
|
|
for _, mode := range routerPreferModes {
|
|
if strings.HasPrefix(mode, lower) && mode != arg {
|
|
return cmd + " " + mode
|
|
}
|
|
}
|
|
case "/profile":
|
|
if arg == "" || len(profileNames) == 0 {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(arg)
|
|
for _, name := range profileNames {
|
|
if strings.HasPrefix(strings.ToLower(name), lower) && name != arg {
|
|
return cmd + " " + name
|
|
}
|
|
}
|
|
case "/provider":
|
|
if arg == "" || len(providerNames) == 0 {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(arg)
|
|
for _, name := range providerNames {
|
|
if strings.HasPrefix(strings.ToLower(name), lower) && name != arg {
|
|
return cmd + " " + name
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|