perf+feat: parallel startup discovery + slash-command suggestion dropdown

Startup: HarvestAliases, HarvestInventory, DiscoverCLIAgents, and
DiscoverLocalModels now run concurrently. Worst case latency drops
from sum(all) to max(all) — eliminates the 15s inventory timeout
from blocking the main path.

TUI: typing '/co' now shows a bordered dropdown of all matching
commands with descriptions. ↑↓ navigate, Tab/Enter accepts the
highlighted entry, Esc dismisses. Ghost-text still works for
unique unambiguous matches.
This commit is contained in:
2026-05-07 17:30:16 +02:00
parent f8867f5d78
commit d2139c6f0c
5 changed files with 243 additions and 72 deletions
+46 -20
View File
@@ -235,20 +235,52 @@ func main() {
reg.Register(fs.NewWriteTool(fs.WithMaxFileSize(cfg.Tools.MaxFileSize)))
}
// Harvest shell aliases
aliases, err := bash.HarvestAliases(context.Background())
if err != nil {
logger.Debug("alias harvest failed (non-fatal)", "error", err)
} else {
// Harvest aliases, inventory, CLI agents, and local models in parallel.
// These are independent and together account for most startup latency.
var (
aliases *bash.AliasMap
inventory *bash.SystemInventory
cliAgents []subprocprov.DiscoveredAgent
localModels []router.DiscoveredModel
)
{
var wg sync.WaitGroup
wg.Add(4)
go func() {
defer wg.Done()
a, err := bash.HarvestAliases(context.Background())
if err != nil {
logger.Debug("alias harvest failed (non-fatal)", "error", err)
} else {
aliases = a
}
}()
go func() {
defer wg.Done()
inventory = bash.HarvestInventory(context.Background())
}()
go func() {
defer wg.Done()
cliAgents = subprocprov.DiscoverCLIAgents(context.Background())
}()
go func() {
defer wg.Done()
localModels = router.DiscoverLocalModels(context.Background(), logger,
cfg.Provider.Endpoints["ollama"],
cfg.Provider.Endpoints["llamacpp"],
nil,
)
}()
wg.Wait()
}
if aliases != nil {
logger.Debug("harvested aliases", "count", aliases.Len())
}
// Harvest system inventory
inventory := bash.HarvestInventory(context.Background())
logger.Debug("system inventory",
"tools", len(inventory.Tools),
"runtimes", len(inventory.Runtimes),
)
if inventory != nil {
logger.Debug("system inventory", "tools", len(inventory.Tools), "runtimes", len(inventory.Runtimes))
} else {
inventory = &bash.SystemInventory{}
}
// Re-register bash tool with aliases and config timeout
bashOpts := []bash.Option{bash.WithAliases(aliases)}
@@ -333,12 +365,7 @@ func main() {
}
}
// Discover local models (ollama + llama.cpp) and register as additional arms
localModels := router.DiscoverLocalModels(context.Background(), logger,
cfg.Provider.Endpoints["ollama"],
cfg.Provider.Endpoints["llamacpp"],
nil, // no cache for initial one-shot discovery
)
// Register local models discovered above in parallel.
router.RegisterDiscoveredModels(rtr, localModels, func(provName, model string) provider.Provider {
p, err := createProvider(provName, "", model, cfg.Provider.Endpoints[provName])
if err != nil {
@@ -350,8 +377,7 @@ func main() {
logger.Debug("local models discovered", "count", len(localModels))
}
// Discover CLI agents (claude, gemini, vibe) and register as arms.
cliAgents := subprocprov.DiscoverCLIAgents(context.Background())
// Register CLI agents discovered above in parallel.
for _, agent := range cliAgents {
cliArmID := router.NewArmID("subprocess", agent.Name)
if _, exists := rtr.LookupArm(cliArmID); !exists {
+41 -4
View File
@@ -95,8 +95,10 @@ type Model struct {
currentRole string
input textarea.Model
suggestion string // ghost-text completion (dimmed, accepted with Tab)
completionSrc []string // sorted slash commands for completion
suggestion string // ghost-text completion (dimmed, accepted with Tab)
completionSrc []cmdEntry // sorted slash commands for completion
suggestions []cmdEntry // live dropdown matches for current input
suggIdx int // selected index in dropdown
mdRenderer *glamour.TermRenderer
mdRendererWidth int // cached width to avoid recreating on same-width resizes
expandOutput bool // ctrl+o toggles expanded tool output
@@ -411,12 +413,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+]":
m.copyMode = !m.copyMode
return m, nil
case "up":
if len(m.suggestions) > 0 {
if m.suggIdx > 0 {
m.suggIdx--
}
return m, nil
}
case "down":
if len(m.suggestions) > 0 {
if m.suggIdx < len(m.suggestions)-1 {
m.suggIdx++
}
return m, nil
}
case "tab":
if len(m.suggestions) > 0 {
// Accept highlighted suggestion and add trailing space for args
m.input.SetValue(m.suggestions[m.suggIdx].name + " ")
m.input.CursorEnd()
m.suggestions = nil
m.suggestion = ""
return m, nil
}
if m.suggestion != "" {
m.input.SetValue(m.suggestion)
m.suggestion = ""
return m, nil
}
case "esc":
if len(m.suggestions) > 0 {
m.suggestions = nil
m.suggestion = ""
return m, nil
}
case "pgup", "shift+up":
m.scrollOffset += 5
return m, nil
@@ -691,8 +721,15 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
// Update slash-command ghost completion.
m.suggestion = matchCompletion(m.input.Value(), m.completionSrc)
// Update slash-command ghost completion and dropdown suggestions.
val := m.input.Value()
m.suggestion = matchCompletion(val, m.completionSrc)
m.suggestions = matchSuggestions(val, m.completionSrc)
if len(m.suggestions) == 0 || !strings.HasPrefix(val, "/") {
m.suggIdx = 0
} else if m.suggIdx >= len(m.suggestions) {
m.suggIdx = len(m.suggestions) - 1
}
return m, tea.Batch(cmds...)
}
+63 -47
View File
@@ -7,28 +7,34 @@ import (
"somegit.dev/Owlibou/gnoma/internal/skill"
)
// builtinCommands is the static list of slash commands.
var builtinCommands = []string{
"/clear",
"/compact",
"/config",
"/exit",
"/help",
"/incognito",
"/init",
"/keys",
"/model",
"/new",
"/perm",
"/permission",
"/plugins",
"/provider",
"/quit",
"/replay",
"/resume",
"/shell",
"/skills",
"/usage",
// 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"},
{"/exit", "exit gnoma"},
{"/help", "show available commands and shortcuts"},
{"/incognito", "toggle incognito mode (no persistence, local-only routing)"},
{"/init", "initialize project — create AGENTS.md"},
{"/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"},
{"/provider", "list or switch provider"},
{"/quit", "quit gnoma"},
{"/replay", "replay last assistant response"},
{"/resume", "browse and resume a saved session"},
{"/shell", "open interactive shell"},
{"/skills", "list available skills"},
{"/usage", "show token usage for this session"},
}
// permissionModes lists valid modes for /permission completion.
@@ -37,45 +43,55 @@ var permissionModes = []string{
}
// completionSource builds a sorted command list from builtins + skills.
func completionSource(skills *skill.Registry) []string {
cmds := make([]string, len(builtinCommands))
copy(cmds, builtinCommands)
func completionSource(skills *skill.Registry) []cmdEntry {
entries := make([]cmdEntry, len(builtinCommands))
copy(entries, builtinCommands)
if skills != nil {
for _, s := range skills.All() {
cmds = append(cmds, "/"+s.Frontmatter.Name)
desc := s.Frontmatter.Description
if desc == "" {
desc = "skill"
}
entries = append(entries, cmdEntry{"/" + s.Frontmatter.Name, desc})
}
}
sort.Strings(cmds)
return cmds
sort.Slice(entries, func(i, j int) bool {
return entries[i].name < entries[j].name
})
return entries
}
// matchCompletion finds the best completion for the current input.
// Returns the full command string if a unique prefix match exists, or empty string.
func matchCompletion(input string, commands []string) string {
// 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.
func matchCompletion(input string, commands []cmdEntry) string {
if !strings.HasPrefix(input, "/") || len(input) < 2 {
return ""
}
// Don't complete if there are args (space after command).
if strings.Contains(input, " ") {
return matchArgCompletion(input)
}
lower := strings.ToLower(input)
var match string
for _, cmd := range commands {
if strings.HasPrefix(cmd, lower) {
if match != "" {
return "" // ambiguous — multiple matches, no ghost text
}
match = cmd
}
suggestions := matchSuggestions(input, commands)
if len(suggestions) == 1 && suggestions[0].name != input {
return suggestions[0].name
}
if match == input {
return "" // already complete
}
return match
return ""
}
// matchArgCompletion handles second-level completion for commands with args.
+9 -1
View File
@@ -3,7 +3,15 @@ package tui
import "testing"
func TestMatchCompletion(t *testing.T) {
cmds := []string{"/clear", "/compact", "/config", "/help", "/model", "/permission", "/quit"}
cmds := []cmdEntry{
{"/clear", "clear history"},
{"/compact", "compact context"},
{"/config", "settings"},
{"/help", "show help"},
{"/model", "switch model"},
{"/permission", "set permission"},
{"/quit", "quit"},
}
tests := []struct {
input string
+84
View File
@@ -108,6 +108,11 @@ func (m Model) renderChat(height int) string {
lines = append(lines, m.renderConfigPanel(m.width)...)
}
// Slash-command suggestion dropdown
if len(m.suggestions) > 0 {
lines = append(lines, m.renderSuggestions()...)
}
// Transient: session resume picker
if m.resumePending && len(m.resumeSessions) > 0 {
lines = append(lines, "")
@@ -618,6 +623,85 @@ func formatTurnUsage(u message.Usage) string {
return strings.Join(parts, " · ")
}
// renderSuggestions renders the slash-command autocomplete dropdown.
func (m Model) renderSuggestions() []string {
const maxVisible = 6
sCmd := lipgloss.NewStyle().Foreground(cPurple).Bold(true)
sDesc := lipgloss.NewStyle().Foreground(cSubtext)
sSelectedCmd := lipgloss.NewStyle().
Background(cSurface).
Foreground(cPurple).
Bold(true)
sSelectedDesc := lipgloss.NewStyle().
Background(cSurface).
Foreground(cText)
// Determine visible window around selected item
start := 0
end := len(m.suggestions)
if end > maxVisible {
end = maxVisible
start = m.suggIdx - maxVisible/2
if start < 0 {
start = 0
}
if start+maxVisible > len(m.suggestions) {
start = len(m.suggestions) - maxVisible
}
end = start + maxVisible
}
// Measure widest command name for alignment
maxCmdW := 0
for _, s := range m.suggestions[start:end] {
if len(s.name) > maxCmdW {
maxCmdW = len(s.name)
}
}
innerW := m.width - 6
if innerW < 40 {
innerW = 40
}
var bodyLines []string
for i, entry := range m.suggestions[start:end] {
idx := start + i
pad := strings.Repeat(" ", maxCmdW-len(entry.name)+1)
desc := entry.desc
// Truncate desc to fit
maxDescW := innerW - maxCmdW - 2
if maxDescW > 0 && len(desc) > maxDescW {
desc = desc[:maxDescW-1] + "…"
}
if idx == m.suggIdx {
line := sSelectedCmd.Render(entry.name) + sSelectedDesc.Render(pad+desc)
// Pad to fill width
lineW := lipgloss.Width(line)
if lineW < innerW {
line += sSelectedDesc.Render(strings.Repeat(" ", innerW-lineW))
}
bodyLines = append(bodyLines, line)
} else {
bodyLines = append(bodyLines, sCmd.Render(entry.name)+sDesc.Render(pad+desc))
}
}
if len(m.suggestions) > maxVisible {
extra := len(m.suggestions) - maxVisible
bodyLines = append(bodyLines, sHint.Render(fmt.Sprintf(" +%d more", extra)))
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cSurface).
Padding(0, 1).
Width(innerW + 2).
Render(strings.Join(bodyLines, "\n"))
return []string{box}
}
// renderConfigPanel renders the interactive /config settings overlay.
func (m Model) renderConfigPanel(width int) []string {
rtr := m.config.Router