package tui import ( "context" "fmt" "os" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "nub/internal/ctxasm" "nub/internal/llm" "nub/internal/scaffold" "nub/internal/session" "nub/internal/skill" "nub/internal/tool" ) func (m *Model) runCommand(text string) tea.Cmd { fields := strings.Fields(text) cmd := fields[0] args := fields[1:] switch cmd { case "/help": m.pushCommand(m.helpText()) return nil case "/clear": m.entries = nil m.renderViewport() return nil case "/exit", "/quit": // Nicht hier canceln, siehe Kommentar bei KeyCtrlC in model.go. m.quitting = true return tea.Quit case "/model": return m.cmdModel(args) case "/context": return m.cmdContext() case "/skills": return m.cmdSkills() case "/mcp": return m.cmdMCP() case "/tree", "/branch": return m.cmdOpenTree() case "/sessions": return m.cmdSessions() case "/compact": return m.cmdCompact() case "/export": return m.cmdExport(args) case "/init": return m.cmdInit() default: if s, ok := m.findSkill(cmd); ok { return m.cmdInvokeSkill(s, args) } m.pushError("unknown command: " + cmd + " (siehe /help)") return nil } } // findSkill matcht einen Slash-Command-Namen (führendes "/" entfernt) gegen // die bekannten Skills. Eingebaute Kommandos gewinnen immer — findSkill wird // nur im default-Fall von runCommand aufgerufen, also erst nachdem kein // eingebautes Kommando gepasst hat. func (m *Model) findSkill(cmd string) (skill.Skill, bool) { name := strings.TrimPrefix(cmd, "/") for _, s := range m.skills { if s.Name == name { return s, true } } return skill.Skill{}, false } // cmdInvokeSkill schickt eine kurze Direktive statt des vollen Skill-Bodys — // das Modell lädt den Body weiterhin selbst über read_skill nach (Progressive // Disclosure bleibt intakt, der Slash-Command ist nur eine Abkürzung dafür, // dem Modell zu sagen, welchen Skill es anwenden soll). func (m *Model) cmdInvokeSkill(s skill.Skill, args []string) tea.Cmd { instruction := fmt.Sprintf("Nutze den Skill %q (via read_skill).", s.Name) if len(args) > 0 { instruction += " " + strings.Join(args, " ") } return m.sendAsMessage(instruction) } // helpCommands ist die einzige Quelle für /help — Reihenfolge und Text // bewusst hier zentralisiert statt verstreut in runCommand. var helpCommands = []struct{ cmd, desc string }{ {"/model [name]", "aktuelles Modell anzeigen, oder für die restliche Session wechseln"}, {"/tree", "Session-Baum anzeigen, mit ↑/↓ einen Knoten wählen"}, {"/branch", "wie /tree — von einem früheren Knoten aus weiterarbeiten (Rewind)"}, {"/sessions", "Sessions auflisten, wechseln (Enter) oder löschen (Entf)"}, {"/compact", "Session sofort zusammenfassen, unabhängig vom Token-Schwellwert"}, {"/context", "geladenen Kontext mit Herkunft und Tokenkosten anzeigen"}, {"/mcp", "verbundene MCP-Server und ihre Tools auflisten"}, {"/skills", "gefundene Skills (Name + Beschreibung) auflisten"}, {"/export [pfad]", "Transkript als Markdown-Datei exportieren"}, {"/init", "Beispiel-Config (.nub/config.toml) und AGENTS.md anlegen, falls nicht vorhanden"}, {"/clear", "nur die Anzeige leeren — die Session bleibt unverändert"}, {"/exit, /quit", "nub beenden"}, {"/help", "diese Übersicht anzeigen"}, } func (m *Model) helpText() string { width := 0 for _, c := range helpCommands { if len(c.cmd) > width { width = len(c.cmd) } } for _, s := range m.skills { if l := len("/" + s.Name); l > width { width = l } } var b strings.Builder b.WriteString("Tasten:\n") b.WriteString(" Enter Nachricht senden\n") b.WriteString(" Alt+Enter während einer laufenden Antwort als Follow-up einreihen\n") b.WriteString(" ↑/↓ Verlauf durchblättern (bei leerem Eingabefeld)\n") b.WriteString("\nKommandos:\n") for _, c := range helpCommands { fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc) } if len(m.skills) > 0 { b.WriteString("\nSkills (als Kommando nutzbar):\n") for _, s := range m.skills { fmt.Fprintf(&b, " %-*s %s\n", width, "/"+s.Name, s.Description) } } return strings.TrimRight(b.String(), "\n") } func (m *Model) cmdModel(args []string) tea.Cmd { if len(args) == 0 { m.pushCommand("current model: " + m.modelName) return nil } m.modelName = args[0] m.loop.Model = args[0] m.pushCommand("model set to " + args[0]) return nil } func (m *Model) cmdContext() tea.Cmd { system, meta, err := ctxasm.Assemble(ctxasm.Options{ RepoRoot: m.loop.Env.RepoRoot, Cwd: m.loop.Env.RepoRoot, Files: m.cfg.Context.Files, WalkUp: m.cfg.Context.WalkUp, MaxTokens: m.cfg.Context.MaxTokens, SkillsIndex: skill.RenderIndex(m.skills), }) if err != nil { m.pushError("context: " + err.Error()) return nil } var b strings.Builder fmt.Fprintf(&b, "context: %d Blöcke, %d Dateitokens (Budget %d)\n", len(system), meta.TotalTokens, m.cfg.Context.MaxTokens) for _, f := range meta.Files { trunc := "" if f.Truncated { trunc = " [truncated]" } fmt.Fprintf(&b, " %-24s %6d tokens %s%s\n", f.Path, f.Tokens, f.Source, trunc) } for _, w := range meta.Warnings { fmt.Fprintf(&b, " warning: %s\n", w) } m.pushCommand(strings.TrimRight(b.String(), "\n")) return nil } func (m *Model) cmdSkills() tea.Cmd { if len(m.skills) == 0 { m.pushCommand("no skills found") return nil } var b strings.Builder b.WriteString("skills:\n") for _, s := range m.skills { fmt.Fprintf(&b, " %-20s %s\n", s.Name, s.Description) } m.pushCommand(strings.TrimRight(b.String(), "\n")) return nil } func (m *Model) cmdMCP() tea.Cmd { if len(m.mcpServers) == 0 { m.pushCommand("no mcp servers connected") return nil } return func() tea.Msg { var b strings.Builder b.WriteString("mcp servers:\n") for _, srv := range m.mcpServers { var names []string for t, err := range srv.Session.Tools(m.ctx, nil) { if err != nil { break } names = append(names, t.Name) } fmt.Fprintf(&b, " %s: %s\n", srv.Name, strings.Join(names, ", ")) } return asyncCommandMsg{text: strings.TrimRight(b.String(), "\n")} } } func (m *Model) cmdCompact() tea.Cmd { return func() tea.Msg { ch := make(chan tool.UIEvent, 4) var got []tool.UIEvent done := make(chan struct{}) go func() { defer close(done) for ev := range ch { got = append(got, ev) } }() ctx, cancel := context.WithTimeout(m.ctx, 60*time.Second) defer cancel() err := m.loop.Compact(ctx, ch) close(ch) <-done return compactDoneMsg{events: got, err: err} } } func (m *Model) cmdExport(args []string) tea.Cmd { path := fmt.Sprintf("nub-export-%s.md", shortID(m.store.ID)) if len(args) > 0 { path = args[0] } var b strings.Builder fmt.Fprintf(&b, "# nub session %s\n\n", m.store.ID) for _, e := range m.entries { b.WriteString(renderEntry(e)) b.WriteString("\n\n") } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { m.pushError("export: " + err.Error()) return nil } m.pushCommand("exported to " + path) return nil } // cmdInit legt .nub/config.toml (Beispiel-Config inkl. Permissions) und ein // minimalistisches AGENTS.md an — nur was noch fehlt, nichts wird // überschrieben. Wirkt erst ab dem nächsten Start von nub (die laufende // Session hat ihre Config schon geladen). func (m *Model) cmdInit() tea.Cmd { res, err := scaffold.Init(m.loop.Env.RepoRoot) if err != nil { m.pushError("init: " + err.Error()) return nil } var b strings.Builder if res.ConfigCreated { fmt.Fprintf(&b, "angelegt: %s\n", res.ConfigPath) } else { fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.ConfigPath) } if res.AgentsCreated { fmt.Fprintf(&b, "angelegt: %s\n", res.AgentsPath) } else { fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.AgentsPath) } if res.ConfigCreated || res.AgentsCreated { b.WriteString("\nWirkt ab dem nächsten Start von nub — diese Session läuft mit der bereits geladenen Config weiter.") } m.pushCommand(strings.TrimRight(b.String(), "\n")) return nil } // --- Generisches Listen-Modal: /tree, /branch, /sessions --- // listModal ist ein wiederverwendbarer Auswahl-Dialog (↑/↓ + Enter), optional // mit Löschen (Entf, mit Rückfrage). onSelect/onDelete laufen im Model- // Kontext, damit sie Session/Store direkt anfassen können. type listModal struct { title string items []listItem cursor int confirmDelete bool onSelect func(m *Model, item listItem) tea.Cmd onDelete func(m *Model, item listItem) tea.Cmd // nil = Löschen nicht angeboten } type listItem struct { id string label string marker string // z.B. "(head)" oder "(aktiv)" protect bool // z.B. aktuelle Session: nicht löschbar } func (m *Model) cmdOpenTree() tea.Cmd { type row struct { id string created time.Time label string isHead bool } var rows []row for id, n := range m.store.Nodes { label := firstLine(textOf(n)) if len(label) > 60 { label = label[:60] + "…" } rows = append(rows, row{id: id, created: n.Meta.CreatedAt, label: label, isHead: id == m.store.Head}) } sort.Slice(rows, func(i, j int) bool { return rows[i].created.Before(rows[j].created) }) items := make([]listItem, len(rows)) cursor := 0 for i, r := range rows { marker := "" if r.isHead { marker = "(head)" cursor = i } items[i] = listItem{id: r.id, label: fmt.Sprintf("%s %s", shortID(r.id), r.label), marker: marker} } m.modal = &listModal{ title: "Session-Baum", items: items, cursor: cursor, onSelect: func(m *Model, item listItem) tea.Cmd { if err := m.store.Branch(item.id); err != nil { m.pushError("branch: " + err.Error()) return nil } m.pushCommand(fmt.Sprintf("head set to %s", shortID(item.id))) return nil }, } return nil } // cmdSessions listet alle Sessions im Projekt, erlaubt Wechseln (Enter) und // Löschen (Entf, mit Rückfrage) — die aktive Session lässt sich nicht // löschen. func (m *Model) cmdSessions() tea.Cmd { infos, err := session.List(m.loop.Env.RepoRoot) if err != nil { m.pushError("sessions: " + err.Error()) return nil } if len(infos) == 0 { m.pushCommand("no sessions in this directory") return nil } items := make([]listItem, len(infos)) cursor := 0 for i, info := range infos { marker := "" active := info.ID == m.store.ID if active { marker = "(aktiv)" cursor = i } summary := info.Summary if summary == "" { summary = "(kein Titel)" } items[i] = listItem{ id: info.ID, label: fmt.Sprintf("%s %-40s %d nodes %s", shortID(info.ID), summary, info.NodeCount, info.Created.Format("2006-01-02 15:04")), marker: marker, protect: active, } } m.modal = &listModal{ title: "Sessions", items: items, cursor: cursor, onSelect: func(m *Model, item listItem) tea.Cmd { if item.id == m.store.ID { m.pushCommand("already on this session") return nil } newStore, err := session.Load(m.loop.Env.RepoRoot, item.id) if err != nil { m.pushError("resume: " + err.Error()) return nil } old := m.store cmd := m.startSession(newStore) _ = old.Close() m.pushCommand("switched to session " + shortID(item.id)) return cmd }, onDelete: func(m *Model, item listItem) tea.Cmd { if item.id == m.store.ID { m.pushError("cannot delete the active session") return nil } if err := session.Delete(m.loop.Env.RepoRoot, item.id); err != nil { m.pushError("delete: " + err.Error()) return nil } m.pushCommand("deleted session " + shortID(item.id)) return nil }, } return nil } func textOf(n *session.Node) string { for _, b := range n.Message.Content { if b.Text != "" { return b.Text } } return string(n.Kind) } func (m *Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { lm := m.modal if lm.confirmDelete { if msg.String() == "y" || msg.String() == "Y" { item := lm.items[lm.cursor] lm.confirmDelete = false m.modal = nil return m, lm.onDelete(m, item) } lm.confirmDelete = false return m, nil } switch msg.Type { case tea.KeyEsc, tea.KeyCtrlC: m.modal = nil return m, nil case tea.KeyUp: if lm.cursor > 0 { lm.cursor-- } return m, nil case tea.KeyDown: if lm.cursor < len(lm.items)-1 { lm.cursor++ } return m, nil case tea.KeyEnter: item := lm.items[lm.cursor] m.modal = nil if lm.onSelect == nil { return m, nil } return m, lm.onSelect(m, item) case tea.KeyDelete: if lm.onDelete == nil || len(lm.items) == 0 { return m, nil } if lm.items[lm.cursor].protect { m.pushError("cannot delete the active session") return m, nil } lm.confirmDelete = true return m, nil } return m, nil } func (m *Model) renderModal() string { lm := m.modal var b strings.Builder if lm.confirmDelete { fmt.Fprintf(&b, "%s wirklich löschen? [y/N]", lm.items[lm.cursor].label) return styleModalBox.Render(b.String()) } fmt.Fprintf(&b, "%s — ↑/↓ wählen, Enter bestätigen", lm.title) if lm.onDelete != nil { b.WriteString(", Entf löscht") } b.WriteString(", Esc abbrechen\n\n") for i, it := range lm.items { line := it.label if it.marker != "" { line += " " + it.marker } if i == lm.cursor { b.WriteString(styleModalSel.Render("> " + line)) } else { b.WriteString(" " + line) } b.WriteString("\n") } return styleModalBox.Render(strings.TrimRight(b.String(), "\n")) } // sumUsage summiert die pro Node gespeicherte Token-Nutzung (NodeMeta.Usage) // einer Session-Historie auf — Grundlage für die Statuszeile beim Start // oder Wechsel einer Session (sonst zeigt sie fälschlich 0/den Stand der // vorherigen Session an, obwohl die tatsächliche Nutzung längst persistiert // ist). func sumUsage(nodes []*session.Node) llm.Usage { var total llm.Usage for _, n := range nodes { total.InputTokens += n.Meta.Usage.InputTokens total.OutputTokens += n.Meta.Usage.OutputTokens total.CacheReadTokens += n.Meta.Usage.CacheReadTokens total.CacheWriteTokens += n.Meta.Usage.CacheWriteTokens } return total } // replayEntries baut das Transkript aus der Historie einer (neu geladenen // oder gewechselten) Session nach — sonst wäre nach /sessions oder einem // `nub resume` in der TUI der bisherige Verlauf unsichtbar, obwohl der // Loop ihn im Kontext hat. func replayEntries(nodes []*session.Node) []entry { var out []entry toolNames := map[string]string{} for _, n := range nodes { if n.Kind == session.NodeSummary { out = append(out, entry{kind: entrySystem, text: "zusammengefasste Vorgeschichte:\n" + textOf(n)}) continue } for _, b := range n.Message.Content { switch b.Kind { case llm.KindText: if b.Text == "" { continue } if n.Message.Role == llm.RoleUser { out = append(out, entry{kind: entryUser, text: b.Text}) } else { out = append(out, entry{kind: entryAssistant, text: b.Text}) } case llm.KindToolUse: toolNames[b.ID] = b.Name out = append(out, entry{kind: entryToolCall, text: renderToolCall(b.Name, b.Input)}) case llm.KindToolResult: name := toolNames[b.ToolUseID] if name == "" { name = "tool" } var resText strings.Builder for _, r := range b.Result { resText.WriteString(r.Text) } out = append(out, entry{ kind: entryToolResult, text: renderToolResult(name, tool.Result{ForModel: resText.String(), IsError: b.IsError}), }) } } } return out }