Files
vikingowl 6c5e969217 feat(tui): add /router command for runtime routing-preference switch
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).
2026-05-24 22:13:27 +02:00

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 ""
}