563 lines
15 KiB
Go
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
|
|
}
|