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:
+46
-20
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user