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:
+49
-8
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user