feat(tui): surface SLM backend + per-turn classifier in status bar

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: <model> ⚙" 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 → <arm> (task: <type>)" 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
This commit is contained in:
2026-05-19 19:06:26 +02:00
parent a14fe8b504
commit 0b4de6054d
7 changed files with 49 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"837af2d6-1ee9-4eab-8a71-097f47600e06","pid":65427,"procStart":"219330","acquiredAt":1779201696258}
+7
View File
@@ -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 {
+4 -3
View File
@@ -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(),
})
}
}
+3 -2
View File
@@ -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
+11
View File
@@ -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.
+6 -4
View File
@@ -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
+17 -1
View File
@@ -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: <model> [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)}