feat: various improvements to engine, router, and TUI

- engine/loop: enhanced loop handling
- router: dynamic model discovery and task improvements
- tui: suggestion box, input mode indicator, completions enhancements
This commit is contained in:
2026-05-07 22:51:50 +02:00
parent 0d2d825e52
commit 135c8afe80
8 changed files with 361 additions and 54 deletions
+49 -8
View File
@@ -141,8 +141,25 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
s, err = e.cfg.Provider.Stream(ctx, req)
}
if err != nil {
var failedArms []router.ArmID
if e.cfg.Router != nil && decision.Arm != nil {
failedArms = append(failedArms, decision.Arm.ID)
}
// If we have a router and no forced arm, we fall back to other models immediately.
skipDelay := e.cfg.Router != nil && e.cfg.Router.ForcedArm() == ""
// Apply temporary backoff to the failing arm if it was a 429
if e.cfg.Router != nil && decision.Arm != nil {
var provErr *provider.ProviderError
if errors.As(err, &provErr) && (provErr.StatusCode == 429 || provErr.StatusCode == 529) {
e.logger.Info("applying backoff to exhausted model", "arm", decision.Arm.ID)
e.cfg.Router.Backoff(decision.Arm.ID, 5*time.Minute)
}
}
// Retry on transient errors (429, 5xx) with exponential backoff
s, err = e.retryOnTransient(ctx, err, func() (stream.Stream, error) {
s, err = e.retryOnTransient(ctx, err, skipDelay, func() (stream.Stream, error) {
if e.cfg.Router != nil {
prompt := ""
for i := len(e.history) - 1; i >= 0; i-- {
@@ -157,9 +174,22 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
} else {
task.EstimatedTokens = int(gnomactx.EstimateTokens(prompt))
}
task.ExcludedArms = failedArms
var retryDecision router.RoutingDecision
s, retryDecision, err = e.cfg.Router.Stream(ctx, task, req)
decision = retryDecision // adopt new reservation on retry
if err == nil {
decision = retryDecision // adopt new reservation on retry
} else if retryDecision.Arm != nil {
failedArms = append(failedArms, retryDecision.Arm.ID)
// Also apply backoff to arms that fail during the fallback retry loop
var provErr *provider.ProviderError
if errors.As(err, &provErr) && (provErr.StatusCode == 429 || provErr.StatusCode == 529) {
e.logger.Info("applying backoff to exhausted model (during fallback)", "arm", retryDecision.Arm.ID)
e.cfg.Router.Backoff(retryDecision.Arm.ID, 5*time.Minute)
}
}
return s, err
}
return e.cfg.Provider.Stream(ctx, req)
@@ -610,7 +640,7 @@ func (e *Engine) handleRequestTooLarge(ctx context.Context, origErr error, req p
// retryOnTransient retries the stream call on 429/5xx with exponential backoff.
// Returns the original error if not retryable or all retries exhausted.
func (e *Engine) retryOnTransient(ctx context.Context, firstErr error, fn func() (stream.Stream, error)) (stream.Stream, error) {
func (e *Engine) retryOnTransient(ctx context.Context, firstErr error, skipDelay bool, fn func() (stream.Stream, error)) (stream.Stream, error) {
var provErr *provider.ProviderError
if !errors.As(firstErr, &provErr) || !provErr.Retryable {
e.logger.Debug("error not retryable",
@@ -634,16 +664,27 @@ func (e *Engine) retryOnTransient(ctx context.Context, firstErr error, fn func()
}
for attempt := range maxRetries {
delay := delays[attempt]
if skipDelay {
delay = 0
}
e.logger.Debug("retrying after transient error",
"attempt", attempt+1,
"delay", delays[attempt],
"delay", delay,
"status", provErr.StatusCode,
)
select {
case <-time.After(delays[attempt]):
case <-ctx.Done():
return nil, ctx.Err()
if delay > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
} else {
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
s, err := fn()
+18
View File
@@ -22,6 +22,10 @@ type Arm struct {
Capabilities provider.Capabilities
Pools []*LimitPool
// BackoffUntil is the time until which this arm is temporarily disabled (e.g. 429).
BackoffUntil time.Time
mu sync.RWMutex
// MaxComplexity is a hard ceiling on task complexity this arm will accept.
// Zero means no ceiling (default for all existing arms).
MaxComplexity float64
@@ -103,3 +107,17 @@ func (p *ArmPerf) Update(ttft time.Duration, outputTokens int, streamDuration ti
}
p.Samples++
}
// SetBackoff sets a temporary disablement until the given time.
func (a *Arm) SetBackoff(until time.Time) {
a.mu.Lock()
defer a.mu.Unlock()
a.BackoffUntil = until
}
// InBackoff returns true if the arm is currently in a backoff period.
func (a *Arm) InBackoff() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return !a.BackoffUntil.IsZero() && time.Now().Before(a.BackoffUntil)
}
+24 -2
View File
@@ -72,15 +72,27 @@ func (r *Router) Select(task Task) RoutingDecision {
return RoutingDecision{Strategy: StrategySingleArm, Arm: arm}
}
// Collect all arms (excluding disabled; filtered to local-only if incognito)
// Collect all arms (excluding disabled, excluded, in-backoff, and local-only mismatches)
allArms := make([]*Arm, 0, len(r.arms))
for _, arm := range r.arms {
if arm.Disabled {
if arm.Disabled || arm.InBackoff() {
continue
}
if r.localOnly && !arm.IsLocal {
continue
}
isExcluded := false
for _, ex := range task.ExcludedArms {
if arm.ID == ex {
isExcluded = true
break
}
}
if isExcluded {
continue
}
allArms = append(allArms, arm)
}
@@ -267,3 +279,13 @@ func (r *Router) Stream(ctx context.Context, task Task, req provider.Request) (s
}
return s, decision, nil
}
// Backoff temporarily disables an arm (e.g. after a 429 error).
func (r *Router) Backoff(id ArmID, duration time.Duration) {
r.mu.RLock()
arm, ok := r.arms[id]
r.mu.RUnlock()
if ok {
arm.SetBackoff(time.Now().Add(duration))
}
}
+1
View File
@@ -68,6 +68,7 @@ type Task struct {
RequiresTools bool
ComplexityScore float64 // 0-1
RequiredEffort provider.EffortLevel // EffortAuto = no constraint on thinking
ExcludedArms []ArmID // Arms to avoid (e.g. due to recent 429 errors)
}
// ValueScore computes a routing value based on priority and type.
+177 -16
View File
@@ -16,6 +16,7 @@ import (
"charm.land/bubbles/v2/textarea"
"charm.land/glamour/v2"
"charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
gnomacfg "somegit.dev/Owlibou/gnoma/internal/config"
"somegit.dev/Owlibou/gnoma/internal/elf"
"somegit.dev/Owlibou/gnoma/internal/skill"
@@ -99,6 +100,7 @@ type Model struct {
completionSrc []cmdEntry // sorted slash commands for completion
suggestions []cmdEntry // live dropdown matches for current input
suggIdx int // selected index in dropdown
inputMode string // "", "command" (/), "execute" (!)
mdRenderer *glamour.TermRenderer
mdRendererWidth int // cached width to avoid recreating on same-width resizes
expandOutput bool // ctrl+o toggles expanded tool output
@@ -160,6 +162,11 @@ func New(sess session.Session, cfg Config) Model {
km.InsertNewline = key.NewBinding(key.WithKeys("shift+enter", "ctrl+j"))
ti.KeyMap = km
// Remove the highlighted cursor-line background so the input blends with the UI.
tiStyles := ti.Styles()
tiStyles.Focused.CursorLine = lipgloss.NewStyle()
ti.SetStyles(tiStyles)
ti.Focus()
cwd, _ := os.Getwd()
@@ -238,6 +245,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Escape = global stop, never quits
if msg.String() == "escape" {
if m.inputMode != "" {
m.inputMode = ""
m.suggestions = nil
m.suggestion = ""
m.suggIdx = 0
m.input.SetValue("")
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
return m, nil
}
if m.resumePending {
m.resumePending = false
m.resumeSessions = nil
@@ -280,8 +301,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, tea.Quit
}
// First press → clear input, show hint, start expiry timer
// First press → clear input, reset mode, show hint, start expiry timer
m.input.SetValue("")
if m.inputMode != "" {
m.inputMode = ""
m.suggestions = nil
m.suggestion = ""
m.suggIdx = 0
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
}
m.lastCtrlC = now
m.quitHint = true
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
@@ -362,6 +395,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil // swallow all other keys
}
// Input mode switching: "/" activates command mode, "!" activates execute mode.
// Only triggers when input is empty and no mode is active.
switch msg.String() {
case "/":
if m.input.Value() == "" && m.inputMode == "" {
m.inputMode = "command"
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return "/ "
}
return " "
})
m.suggestions = m.completionSrc
m.suggIdx = 0
return m, nil
}
case "!":
if m.input.Value() == "" && m.inputMode == "" {
m.inputMode = "execute"
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return "! "
}
return " "
})
m.suggestions = nil
m.suggIdx = 0
return m, nil
}
case "backspace":
if m.input.Value() == "" && m.inputMode != "" {
m.inputMode = ""
m.suggestions = nil
m.suggestion = ""
m.suggIdx = 0
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
return m, nil
}
}
switch msg.String() {
case "ctrl+x":
// Toggle incognito
@@ -429,8 +507,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "tab":
if len(m.suggestions) > 0 {
// Accept highlighted suggestion and add trailing space for args
m.input.SetValue(m.suggestions[m.suggIdx].name + " ")
name := m.suggestions[m.suggIdx].name
if m.inputMode == "command" && strings.HasPrefix(name, "/") {
// In command mode the prompt shows "/"; strip it from the value
name = name[1:]
}
m.input.SetValue(name + " ")
m.input.CursorEnd()
m.suggestions = nil
m.suggestion = ""
@@ -466,8 +548,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.streaming {
return m, nil
}
// Command mode + open dropdown: Enter selects the highlighted suggestion
if m.inputMode == "command" && len(m.suggestions) > 0 {
selected := m.suggestions[m.suggIdx].name // e.g., "/help"
m.input.SetValue("")
m.suggestions = nil
return m.submitInput(selected[1:]) // strip "/" — submitInput will re-add it
}
input := strings.TrimSpace(m.input.Value())
if input == "" {
// Empty Enter in any mode resets the mode
if m.inputMode != "" {
m.inputMode = ""
m.suggestions = nil
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
}
return m, nil
}
m.input.SetValue("")
@@ -579,7 +679,7 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
// Send with empty AllowedTools to suppress all tool schemas.
opts := engine.TurnOptions{AllowedTools: []string{}}
if err := m.session.SendWithOptions(textPrompt, opts); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
m.initPending = false
}
@@ -614,7 +714,7 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
nudge = "Call fs_ls on the project root now. Then fs_read go.mod and Makefile. Then fs_glob **/*.go to find source files. Finally fs_write AGENTS.md. Do not explain — call the tools."
}
if err := m.session.Send(nudge); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
m.initPending = false
}
@@ -642,7 +742,7 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
// below will write whatever the model outputs to disk.
writeNudge := "Output the complete AGENTS.md document now as markdown text. Include: project overview, module path, build commands (make build/test/lint/cover), all dependencies, and coding conventions from the elf research. Do not call any tools — output the markdown document directly, starting with a # heading."
if err := m.session.Send(writeNudge); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
m.initPending = false
}
@@ -699,7 +799,7 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
})
}
if msg.err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: msg.err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(msg.err)})
}
if m.initPending {
m.initPending = false
@@ -721,11 +821,22 @@ 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 and dropdown suggestions.
// Update completions based on input mode.
val := m.input.Value()
m.suggestion = matchCompletion(val, m.completionSrc)
m.suggestions = matchSuggestions(val, m.completionSrc)
if len(m.suggestions) == 0 || !strings.HasPrefix(val, "/") {
switch m.inputMode {
case "command":
// Fuzzy-filter commands by the raw query (no "/" prefix in val)
m.suggestions = fuzzyMatchCommands(val, m.completionSrc)
m.suggestion = ""
case "execute":
m.suggestions = nil
m.suggestion = ""
default:
// Normal mode: prefix-based ghost text and dropdown
m.suggestion = matchCompletion(val, m.completionSrc)
m.suggestions = matchSuggestions(val, m.completionSrc)
}
if len(m.suggestions) == 0 {
m.suggIdx = 0
} else if m.suggIdx >= len(m.suggestions) {
m.suggIdx = len(m.suggestions) - 1
@@ -735,6 +846,44 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet
}
func (m Model) submitInput(input string) (tea.Model, tea.Cmd) {
// Prepend mode prefix and reset mode before dispatching.
if m.inputMode == "command" {
if strings.TrimSpace(input) == "" {
m.inputMode = ""
m.suggestions = nil
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
return m, nil
}
input = "/" + strings.TrimSpace(input)
} else if m.inputMode == "execute" {
if strings.TrimSpace(input) == "" {
m.inputMode = ""
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
return m, nil
}
input = "!" + strings.TrimSpace(input)
}
m.inputMode = ""
m.suggestions = nil
m.suggestion = ""
m.suggIdx = 0
m.input.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
if strings.HasPrefix(input, "/") {
return m.handleCommand(input)
}
@@ -750,7 +899,7 @@ func (m Model) submitInput(input string) (tea.Model, tea.Cmd) {
m.streamFilterClose = ""
if err := m.session.Send(input); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
return m, nil
}
@@ -842,7 +991,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
before := w.Tracker().Used()
compacted, err := w.ForceCompact()
if err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + formatError(err)})
} else if compacted {
after := w.Tracker().Used()
msg := fmt.Sprintf("context compacted — %dk → %dk tokens (saved %dk)",
@@ -955,7 +1104,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
return m, nil
}
if err := gnomacfg.SetProjectConfig(parts[0], parts[1]); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
} else {
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("config set: %s = %s (saved to .gnoma/config.toml)", parts[0], parts[1])})
@@ -1096,7 +1245,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
opts := engine.TurnOptions{}
if err := m.session.SendWithOptions(prompt, opts); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
m.initPending = false
return m, nil
@@ -1261,7 +1410,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
AllowedPaths: sk.Frontmatter.Paths,
}
if err := m.session.SendWithOptions(rendered, skillOpts); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.messages = append(m.messages, chatMessage{role: "error", content: formatError(err)})
m.streaming = false
return m, nil
}
@@ -1666,3 +1815,15 @@ func detectGitBranch() string {
}
return strings.TrimSpace(string(out))
}
// formatError truncates excessively long error messages to prevent breaking the TUI rendering.
func formatError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if len(msg) > 1000 {
return msg[:1000] + "\n... [error truncated due to size]"
}
return msg
}
+31
View File
@@ -94,6 +94,37 @@ func matchCompletion(input string, commands []cmdEntry) string {
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 := c.name
if strings.HasPrefix(name, "/") {
name = name[1:]
}
if fuzzyMatch(query, name) {
matches = append(matches, c)
}
}
return matches
}
// matchArgCompletion handles second-level completion for commands with args.
func matchArgCompletion(input string) string {
parts := strings.SplitN(input, " ", 2)
+60 -1
View File
@@ -1,6 +1,8 @@
package tui
import "testing"
import (
"testing"
)
func TestMatchCompletion(t *testing.T) {
cmds := []cmdEntry{
@@ -39,6 +41,63 @@ func TestMatchCompletion(t *testing.T) {
}
}
func TestFuzzyMatch(t *testing.T) {
tests := []struct {
pattern string
text string
want bool
}{
{"hlp", "help", true},
{"clr", "clear", true},
{"mdl", "model", true},
{"help", "help", true}, // exact match
{"HELP", "help", true}, // case insensitive
{"xyz", "help", false}, // no match
{"", "help", true}, // empty pattern matches everything
{"hx", "help", false}, // x not present
{"elp", "help", true}, // subsequence not at start
}
for _, tt := range tests {
got := fuzzyMatch(tt.pattern, tt.text)
if got != tt.want {
t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.pattern, tt.text, got, tt.want)
}
}
}
func TestFuzzyMatchCommands(t *testing.T) {
cmds := []cmdEntry{
{"/clear", "clear history"},
{"/compact", "compact context"},
{"/config", "settings"},
{"/help", "show help"},
{"/model", "switch model"},
}
tests := []struct {
query string
wantLen int
wantFirst string
}{
{"", 5, "/clear"}, // empty = all commands
{"h", 1, "/help"}, // only /help contains h as subsequence
{"hel", 1, "/help"}, // only /help
{"mdl", 1, "/model"}, // subsequence match
{"xyz", 0, ""}, // no match
}
for _, tt := range tests {
got := fuzzyMatchCommands(tt.query, cmds)
if len(got) != tt.wantLen {
t.Errorf("fuzzyMatchCommands(%q): got %d results, want %d (got: %v)", tt.query, len(got), tt.wantLen, got)
}
if tt.wantFirst != "" && len(got) > 0 && got[0].name != tt.wantFirst {
t.Errorf("fuzzyMatchCommands(%q): first result = %q, want %q", tt.query, got[0].name, tt.wantFirst)
}
}
}
func TestMatchArgCompletion(t *testing.T) {
tests := []struct {
input string
+1 -27
View File
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"image/color"
"os"
"path/filepath"
"strings"
@@ -506,32 +505,7 @@ func (m Model) renderSeparators() (string, string) {
labelStyle.Render(label) +
lineStyle.Render(strings.Repeat("─", rightW))
// Bottom line: show input mode indicator when typing / or !
inputVal := m.input.Value()
var inputModeLabel string
var inputModeColor color.Color
switch {
case strings.HasPrefix(inputVal, "/"):
inputModeLabel = " cmd "
inputModeColor = cPurple
case strings.HasPrefix(inputVal, "!"):
inputModeLabel = " exec "
inputModeColor = cYellow
}
var bottomLine string
if inputModeLabel != "" {
imStyle := lipgloss.NewStyle().Foreground(inputModeColor).Bold(true)
imW := lipgloss.Width(imStyle.Render(inputModeLabel))
fillW := m.width - imW
if fillW < 0 {
fillW = 0
}
bottomLine = lipgloss.NewStyle().Foreground(cSurface).Render(strings.Repeat("─", fillW)) +
imStyle.Render(inputModeLabel)
} else {
bottomLine = lineStyle.Render(strings.Repeat("─", m.width))
}
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
return topLine, bottomLine
}