From 135c8afe8057e8d433ce9cd2fab6fccba7ef0275 Mon Sep 17 00:00:00 2001 From: vikingowl <26+vikingowl@noreply.somegit.dev> Date: Thu, 7 May 2026 22:51:50 +0200 Subject: [PATCH] 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 --- internal/engine/loop.go | 57 +++++++-- internal/router/arm.go | 18 +++ internal/router/router.go | 26 ++++- internal/router/task.go | 1 + internal/tui/app.go | 193 ++++++++++++++++++++++++++++--- internal/tui/completions.go | 31 +++++ internal/tui/completions_test.go | 61 +++++++++- internal/tui/rendering.go | 28 +---- 8 files changed, 361 insertions(+), 54 deletions(-) diff --git a/internal/engine/loop.go b/internal/engine/loop.go index d4c114d..95ebe91 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -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() diff --git a/internal/router/arm.go b/internal/router/arm.go index c5b79d6..25a9780 100644 --- a/internal/router/arm.go +++ b/internal/router/arm.go @@ -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) +} diff --git a/internal/router/router.go b/internal/router/router.go index 84c6e72..a3c6205 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)) + } +} diff --git a/internal/router/task.go b/internal/router/task.go index 09fcf55..2d6982b 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -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. diff --git a/internal/tui/app.go b/internal/tui/app.go index 8b19d2e..ab3fbc5 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 +} diff --git a/internal/tui/completions.go b/internal/tui/completions.go index a2f13cc..909c2a7 100644 --- a/internal/tui/completions.go +++ b/internal/tui/completions.go @@ -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) diff --git a/internal/tui/completions_test.go b/internal/tui/completions_test.go index ff19562..6a9f2ed 100644 --- a/internal/tui/completions_test.go +++ b/internal/tui/completions_test.go @@ -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 diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 6c676b8..63a2420 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -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 }