nub/internal/ui/tui/commands.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

563 lines
15 KiB
Go

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("Kommandos:\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
}