From d2139c6f0c2cb488f4a70e7f90916eaf649e1373 Mon Sep 17 00:00:00 2001 From: vikingowl <26+vikingowl@noreply.somegit.dev> Date: Thu, 7 May 2026 17:30:16 +0200 Subject: [PATCH] perf+feat: parallel startup discovery + slash-command suggestion dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/gnoma/main.go | 66 +++++++++++++------ internal/tui/app.go | 45 +++++++++++-- internal/tui/completions.go | 110 ++++++++++++++++++------------- internal/tui/completions_test.go | 10 ++- internal/tui/rendering.go | 84 +++++++++++++++++++++++ 5 files changed, 243 insertions(+), 72 deletions(-) diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 193430b..daa4135 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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 { diff --git a/internal/tui/app.go b/internal/tui/app.go index 75fec78..75abcad 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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...) } diff --git a/internal/tui/completions.go b/internal/tui/completions.go index f8ca51a..a2f13cc 100644 --- a/internal/tui/completions.go +++ b/internal/tui/completions.go @@ -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. diff --git a/internal/tui/completions_test.go b/internal/tui/completions_test.go index 6132f9b..ff19562 100644 --- a/internal/tui/completions_test.go +++ b/internal/tui/completions_test.go @@ -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 diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index d471c40..b03b0e9 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -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