From 0b4de6054d91c0dfd2969a62e385d004d282e97f Mon Sep 17 00:00:00 2001 From: vikingowl <26+vikingowl@noreply.somegit.dev> Date: Tue, 19 May 2026 19:06:26 +0200 Subject: [PATCH] feat(tui): surface SLM backend + per-turn classifier in status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI gave no indication that an SLM was configured or active. You'd see the primary provider on the status line and nothing else, even with [slm].enabled=true and a successfully booted backend. Two surfaces added: 1. Status-bar SLM badge. The left side of the status line gains a dim " · slm: ⚙" suffix when the backend booted, " · slm: ✗" when it failed, and nothing when SLM is disabled. The ⚙ marker indicates the model advertises tool support. 2. Per-turn classifier visibility. The existing routing event already produced "routed → (task: )" lines in the chat history; it now also reports which classifier made the decision, e.g. "routed → ollama/ministral-3:3b (task: explain, by: slm_fallback)". Lets you tell in real time whether the SLM is actually classifying or falling back to the keyword heuristic. Plumbing: - new tui.SLMInfo struct on tui.Config - main.go populates it after StartBackend returns - stream.Event gains RoutingClassifier; engine.runLoop fills it from task.ClassifierSource on the first round --- .claude/scheduled_tasks.lock | 1 + cmd/gnoma/main.go | 7 +++++++ internal/engine/loop.go | 7 ++++--- internal/stream/event.go | 5 +++-- internal/tui/app.go | 11 +++++++++++ internal/tui/events.go | 10 ++++++---- internal/tui/rendering.go | 18 +++++++++++++++++- 7 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..a3fc86a --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"837af2d6-1ee9-4eab-8a71-097f47600e06","pid":65427,"procStart":"219330","acquiredAt":1779201696258} \ No newline at end of file diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index f6e89ea..ebb8a36 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -671,7 +671,9 @@ func main() { lazy := &lazyClassifier{logger: logger} var engineClassifier router.TaskClassifier = lazy var slmCleanup func() error + var slmInfo tui.SLMInfo if cfg.SLM.Enabled { + slmInfo.Enabled = true bcfg := slm.BackendConfig{ Backend: slm.Backend(cfg.SLM.Backend), Model: cfg.SLM.Model, @@ -704,6 +706,10 @@ func main() { Capabilities: provider.Capabilities{ToolUse: boot.ToolSupport}, }) slmCleanup = boot.Close + slmInfo.Active = true + slmInfo.Backend = string(boot.Backend) + slmInfo.Model = boot.Model + slmInfo.Tools = boot.ToolSupport toolNote := "no tools" if boot.ToolSupport { toolNote = "tools" @@ -914,6 +920,7 @@ func main() { PluginInfos: buildPluginInfos(discoveredPlugins, enabledSet), Version: buildVersion, ModelUpdateCh: modelUpdateCh, + SLM: slmInfo, }) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { diff --git a/internal/engine/loop.go b/internal/engine/loop.go index adb44df..a08cdc7 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -134,9 +134,10 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) { ) if turn.Rounds == 1 && cb != nil { cb(stream.Event{ - Type: stream.EventRouting, - RoutingModel: string(decision.Arm.ID), - RoutingTask: task.Type.String(), + Type: stream.EventRouting, + RoutingModel: string(decision.Arm.ID), + RoutingTask: task.Type.String(), + RoutingClassifier: task.ClassifierSource.String(), }) } } diff --git a/internal/stream/event.go b/internal/stream/event.go index e688f7b..a2b27fa 100644 --- a/internal/stream/event.go +++ b/internal/stream/event.go @@ -76,8 +76,9 @@ type Event struct { Usage *message.Usage // Routing — arm selected by router - RoutingModel string // e.g. "anthropic/claude-sonnet-4-20250514" - RoutingTask string // classified task type + RoutingModel string // e.g. "anthropic/claude-sonnet-4-20250514" + RoutingTask string // classified task type + RoutingClassifier string // classifier source: heuristic / slm / slm_fallback // Error Err error diff --git a/internal/tui/app.go b/internal/tui/app.go index cf0f84a..c10ebba 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -71,6 +71,17 @@ type Config struct { PluginInfos []PluginInfo // discovered plugins for /plugins command Version string // build version string (from ldflags) ModelUpdateCh <-chan struct{} // signals when the model name changes (discovery reconciliation) + SLM SLMInfo // SLM backend status for the status bar +} + +// SLMInfo captures the resolved SLM backend state at startup so the TUI can +// surface it in the status bar. Zero value (Enabled=false) renders nothing. +type SLMInfo struct { + Enabled bool + Active bool // true when StartBackend returned a usable Boot + Backend string // resolved backend name: "ollama", "llamafile", etc. + Model string // model identifier + Tools bool // whether the model advertises tool support } // PluginInfo is a summary of an installed plugin for TUI display. diff --git a/internal/tui/events.go b/internal/tui/events.go index 0d7f05f..6521989 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -48,10 +48,12 @@ func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) { m.runningTools = append(m.runningTools, evt.ToolCallName) } case stream.EventRouting: - m.messages = append(m.messages, chatMessage{ - role: "cost", - content: fmt.Sprintf("routed → %s (task: %s)", evt.RoutingModel, evt.RoutingTask), - }) + content := fmt.Sprintf("routed → %s (task: %s", evt.RoutingModel, evt.RoutingTask) + if evt.RoutingClassifier != "" { + content += ", by: " + evt.RoutingClassifier + } + content += ")" + m.messages = append(m.messages, chatMessage{role: "cost", content: content}) case stream.EventToolResult: if m.elfToolActive { // Suppress raw elf output — tree shows progress, LLM summarizes diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index aa6ae04..fbdd86e 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -534,7 +534,7 @@ func (m Model) renderStatus() string { if !status.ToolsAvailable { provModel += " " + sStatusDim.Render("text-only") } - left := sStatusHighlight.Render(provModel) + left := sStatusHighlight.Render(provModel) + renderSLMBadge(m.config.SLM) // Center: cwd + git branch dir := filepath.Base(m.cwd) @@ -615,6 +615,22 @@ func renderContextBar(s session.Status) string { return "[" + bar + "]" + labelStyle.Render(label) } +// renderSLMBadge produces a short " · slm: [tools]" badge for the +// status bar's left side. Returns "" when SLM is disabled or unconfigured. +func renderSLMBadge(info SLMInfo) string { + if !info.Enabled { + return "" + } + if !info.Active { + return sStatusDim.Render(" · slm: ✗") + } + label := " · slm: " + info.Model + if info.Tools { + label += " ⚙" + } + return sStatusDim.Render(label) +} + // formatTurnUsage produces a compact token summary for a single turn. func formatTurnUsage(u message.Usage) string { parts := []string{fmt.Sprintf("in: %d", u.InputTokens), fmt.Sprintf("out: %d", u.OutputTokens)}