nub/internal/ui/tui/model.go
Tom bd7e13385d Update 0.2.0
- siehe Changelog
2026-07-27 20:06:22 +02:00

599 lines
16 KiB
Go

// Package tui ist die Bubbletea-Oberfläche (M6). Reiner Consumer von
// tool.UIEvent — kein anderes Paket importiert dieses hier, das Entfernen
// von internal/ui/tui lässt den Rest kompilieren und -p weiterhin
// funktionieren.
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"nub/internal/agent"
"nub/internal/config"
"nub/internal/llm"
"nub/internal/mcpc"
"nub/internal/session"
"nub/internal/skill"
"nub/internal/tool"
)
type Model struct {
ctx context.Context
loop *agent.Loop
store *session.Store
cfg config.Config
skills []skill.Skill
mcpServers []*mcpc.Server
// sessionGen wird bei jedem Session-Wechsel (startSession) erhöht.
// Events aus einem vorherigen out-Channel tragen die alte Generation
// und werden ignoriert, statt fälschlich die ganze TUI zu beenden.
sessionGen int
sessionCancel context.CancelFunc
in chan agent.Input
out <-chan tool.UIEvent
viewport viewport.Model
textarea textarea.Model
spinner spinner.Model
entries []entry
liveText strings.Builder
liveThink strings.Builder
hasLiveText bool
hasLiveThink bool
history []string
historyIdx int
followupQueue []string
modelName string
tokensUsed int
cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7)
turnActive bool
// activeTools sind die gerade laufenden Tool-Calls (ID -> Name), in
// Start-Reihenfolge. Bei parallelen Calls (mehrere Tools gleichzeitig,
// siehe Caps.ParallelToolCalls) zeigt die Statuszeile alle Namen statt
// nur den zuletzt gestarteten zu überschreiben.
activeTools []toolStatus
modal *listModal
// permCh liefert "ask"-Rückfragen des Backend-Loops (E-11); permReq ist
// die gerade angezeigte, noch unbeantwortete Anfrage.
permCh chan permissionRequest
permReq *permissionRequest
// askCh liefert Freitext-Rückfragen des question-Tools (E-11-Erweiterung);
// askReq ist die gerade angezeigte, noch unbeantwortete Anfrage. Antworten
// laufen über das reguläre Eingabefeld, siehe submit() und question.go.
askCh chan askRequest
askReq *askRequest
width, height int
ready bool
quitting bool
fatalErr error
}
// defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird
// während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben
// (siehe askUser/answerQuestion in question.go).
const defaultInputPlaceholder = "Nachricht eingeben — /help für Kommandos"
const askInputPlaceholder = "Antwort eingeben — Enter sendet"
type toolStatus struct {
id string
name string
}
// addActiveTool/removeActiveTool pflegen m.activeTools für die Statuszeile.
func (m *Model) addActiveTool(id, name string) {
m.activeTools = append(m.activeTools, toolStatus{id: id, name: name})
}
func (m *Model) removeActiveTool(id string) {
for i, t := range m.activeTools {
if t.id == id {
m.activeTools = append(m.activeTools[:i], m.activeTools[i+1:]...)
return
}
}
}
type uiEventMsg struct {
gen int
ev tool.UIEvent
}
type eventsClosedMsg struct{ gen int }
type compactDoneMsg struct {
events []tool.UIEvent
err error
}
type asyncCommandMsg struct{ text string }
func New(ctx context.Context, loop *agent.Loop, store *session.Store, cfg config.Config, skills []skill.Skill, mcpServers []*mcpc.Server) *Model {
ta := textarea.New()
ta.Placeholder = defaultInputPlaceholder
ta.ShowLineNumbers = false
ta.SetHeight(3)
vp := viewport.New(80, 20)
vp.MouseWheelEnabled = true
sp := spinner.New(spinner.WithSpinner(spinner.Dot))
m := &Model{
ctx: ctx,
loop: loop,
cfg: cfg,
skills: skills,
mcpServers: mcpServers,
viewport: vp,
textarea: ta,
spinner: sp,
modelName: loop.Model,
permCh: make(chan permissionRequest),
askCh: make(chan askRequest),
}
loop.RequestPermission = m.requestPermission
loop.AskUser = m.askUser
m.startSession(store)
return m
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(
m.textarea.Focus(),
m.spinner.Tick,
listenEvents(m.sessionGen, m.out),
listenPermissionRequests(m.permCh),
listenAskRequests(m.askCh),
)
}
func listenEvents(gen int, out <-chan tool.UIEvent) tea.Cmd {
return func() tea.Msg {
ev, ok := <-out
if !ok {
return eventsClosedMsg{gen: gen}
}
return uiEventMsg{gen: gen, ev: ev}
}
}
// startSession bindet den Loop an eine (neue oder bestehende) Session:
// stoppt den vorherigen Backend-Loop sauber (falls einer läuft), öffnet
// frische in/out-Channels, und baut das Transkript aus der Historie der
// Session neu auf. Wird sowohl beim ersten Start als auch bei /sessions
// (Wechsel) verwendet.
func (m *Model) startSession(store *session.Store) tea.Cmd {
if m.sessionCancel != nil {
m.sessionCancel()
}
sessCtx, cancel := context.WithCancel(m.ctx)
m.sessionCancel = cancel
m.sessionGen++
m.store = store
m.loop.Session = store
m.in = make(chan agent.Input)
m.out = m.loop.Run(sessCtx, m.in)
nodes := store.PathNodes()
m.entries = replayEntries(nodes)
usage := sumUsage(nodes)
m.tokensUsed = usage.InputTokens + usage.OutputTokens
m.cacheReadTokens = usage.CacheReadTokens
m.turnActive = false
m.activeTools = nil
m.followupQueue = nil
m.liveText.Reset()
m.liveThink.Reset()
m.hasLiveText = false
m.hasLiveThink = false
m.renderViewport()
return listenEvents(m.sessionGen, m.out)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
taHeight := 3
statusHeight := 1
gapHeight := 1 // Leerzeile zwischen Nachrichtenliste und Eingabefeld
vpHeight := msg.Height - taHeight - statusHeight - gapHeight - 1
if vpHeight < 3 {
vpHeight = 3
}
m.viewport.Width = msg.Width
m.viewport.Height = vpHeight
m.textarea.SetWidth(msg.Width)
m.renderViewport()
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.MouseMsg:
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case uiEventMsg:
if msg.gen != m.sessionGen {
return m, nil // Event aus einer inzwischen gewechselten Session
}
cmd := m.handleUIEvent(msg.ev)
return m, tea.Batch(cmd, listenEvents(msg.gen, m.out))
case eventsClosedMsg:
if msg.gen != m.sessionGen {
return m, nil // alter Loop wurde bewusst wegen Session-Wechsel beendet
}
m.quitting = true
return m, tea.Quit
case compactDoneMsg:
if msg.err != nil {
m.pushError("compact: " + msg.err.Error())
}
for _, ev := range msg.events {
m.handleUIEvent(ev)
}
if len(msg.events) == 0 && msg.err == nil {
m.pushCommand("nothing to compact (session too short)")
}
return m, nil
case asyncCommandMsg:
m.pushCommand(msg.text)
return m, nil
case permissionRequestMsg:
m.permReq = &msg.req
m.entries = append(m.entries, entry{kind: entryPermission, text: renderPermissionRequest(msg.req.tool, msg.req.input)})
m.renderViewport()
return m, listenPermissionRequests(m.permCh)
case askRequestMsg:
m.askReq = &msg.req
m.textarea.Placeholder = askInputPlaceholder
m.entries = append(m.entries, entry{kind: entryQuestion, text: renderAskRequest(msg.req.question, msg.req.options)})
m.renderViewport()
return m, listenAskRequests(m.askCh)
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.permReq != nil {
return m.handlePermissionKey(msg)
}
if m.modal != nil {
return m.handleModalKey(msg)
}
switch msg.Type {
case tea.KeyCtrlC:
// Nicht hier canceln: runCtx läuft auch in tea.WithContext ein, ein
// Cancel von innen lässt Program.Run() mit "program was killed"
// zurückkehren statt sauber über tea.Quit zu beenden. run.go
// canceled den Kontext, nachdem Program.Run() zurückgekehrt ist.
m.quitting = true
return m, tea.Quit
case tea.KeyEnter:
if msg.Alt {
return m, m.submit(m.textarea.Value(), true)
}
return m, m.submit(m.textarea.Value(), false)
case tea.KeyUp:
if strings.TrimSpace(m.textarea.Value()) == "" && len(m.history) > 0 {
if m.historyIdx > 0 {
m.historyIdx--
}
m.textarea.SetValue(m.history[m.historyIdx])
return m, nil
}
case tea.KeyDown:
if len(m.history) > 0 && m.historyIdx < len(m.history) {
m.historyIdx++
if m.historyIdx == len(m.history) {
m.textarea.SetValue("")
} else {
m.textarea.SetValue(m.history[m.historyIdx])
}
return m, nil
}
case tea.KeyPgUp:
m.viewport.PageUp()
return m, nil
case tea.KeyPgDown:
m.viewport.PageDown()
return m, nil
case tea.KeyCtrlU:
m.viewport.HalfPageUp()
return m, nil
case tea.KeyCtrlD:
m.viewport.HalfPageDown()
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *Model) View() string {
if !m.ready {
return "initializing…"
}
if m.quitting {
return "bye\n"
}
// Eine offene Permission-Rückfrage überlagert den Bildschirm bewusst
// NICHT (mehr) — sie steht als eigener Block im Transkript (siehe
// permissionRequestMsg in Update), damit der bisherige Verlauf für die
// Entscheidung sichtbar bleibt. Nur ein /tree- o.ä. Modal ist noch ein
// echtes Vollbild-Overlay, weil es kein Teil des Gesprächsflusses ist.
if m.modal != nil {
return m.renderModal()
}
return m.viewport.View() + "\n\n" + m.textarea.View() + "\n" + m.renderStatusLine()
}
func (m *Model) renderStatusLine() string {
state := "idle"
if m.turnActive {
state = m.spinner.View() + " running"
if len(m.activeTools) > 0 {
names := make([]string, len(m.activeTools))
for i, t := range m.activeTools {
names[i] = t.name
}
state += ": " + strings.Join(names, ", ")
}
}
if m.permReq != nil {
state = stylePermissionPending.Render("⚠ permission required — y=allow, other=deny")
} else if m.askReq != nil {
state = styleAskPending.Render("❓ question pending — type your answer and press Enter")
}
queued := ""
if n := len(m.followupQueue); n > 0 {
queued = fmt.Sprintf(" | queued: %d", n)
}
cache := ""
if m.cacheReadTokens > 0 {
cache = fmt.Sprintf(" (cached: %d)", m.cacheReadTokens)
}
line := fmt.Sprintf(" %s | model: %s | tokens: %d%s | session: %s%s ",
state, m.modelName, m.tokensUsed, cache, shortID(m.store.ID), queued)
return styleStatusBar.Width(m.width).Render(line)
}
func shortID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
// pushSystem meldet ein Hintergrund-Ereignis, das nicht vom Nutzer
// angestoßen wurde (z.B. Auto-Compaction).
func (m *Model) pushSystem(text string) {
m.entries = append(m.entries, entry{kind: entrySystem, text: text})
m.renderViewport()
}
// pushCommand zeigt die Ausgabe eines vom Nutzer getippten Slash-Kommandos.
func (m *Model) pushCommand(text string) {
m.entries = append(m.entries, entry{kind: entryCommand, text: text})
m.renderViewport()
}
// pushError meldet einen echten Fehler (Loop-/Provider-Fehler oder ein
// fehlgeschlagenes Kommando).
func (m *Model) pushError(text string) {
m.entries = append(m.entries, entry{kind: entryError, text: text})
m.renderViewport()
}
// renderViewport baut den sichtbaren Transkript-Text aus allen committeten
// Entries plus dem gerade laufenden Streaming-Text neu zusammen.
func (m *Model) renderViewport() {
var b strings.Builder
for i, e := range m.entries {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(wrapForViewport(renderEntry(e), entryWrapWidth(e.kind, m.viewport.Width)))
}
if m.hasLiveThink {
if len(m.entries) > 0 {
b.WriteString("\n\n")
}
b.WriteString(wrapForViewport(styleThinking.Render(m.liveThink.String()), entryWrapWidth(entryThinking, m.viewport.Width)))
}
if m.hasLiveText {
if len(m.entries) > 0 || m.hasLiveThink {
b.WriteString("\n\n")
}
b.WriteString(wrapForViewport(styleAssistant.Render(m.liveText.String()), entryWrapWidth(entryAssistant, m.viewport.Width)))
}
atBottom := m.viewport.AtBottom()
m.viewport.SetContent(b.String())
if atBottom {
m.viewport.GotoBottom()
}
}
// commitLive schließt den aktuellen Streaming-Text als fertigen Eintrag ab
// (mit Markdown-Rendering) und leert den Live-Puffer.
func (m *Model) commitLive() {
if m.hasLiveThink {
m.entries = append(m.entries, entry{kind: entryThinking, text: m.liveThink.String()})
m.liveThink.Reset()
m.hasLiveThink = false
}
if m.hasLiveText {
m.entries = append(m.entries, entry{kind: entryAssistant, text: m.liveText.String()})
m.liveText.Reset()
m.hasLiveText = false
}
}
func (m *Model) submit(text string, alt bool) tea.Cmd {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
m.textarea.Reset()
if m.askReq != nil {
m.answerQuestion(text)
return nil
}
m.history = append(m.history, text)
m.historyIdx = len(m.history)
if strings.HasPrefix(text, "/") {
return m.runCommand(text)
}
if alt && m.turnActive {
m.followupQueue = append(m.followupQueue, text)
m.entries = append(m.entries, entry{kind: entryQueued, text: text})
m.renderViewport()
return nil
}
return m.sendAsMessage(text)
}
// sendAsMessage schickt text als echte Chat-Nachricht ab: sichtbar im
// Transkript und an den Backend-Loop. Wird sowohl für normale Eingaben als
// auch für Skill-Slash-Commands genutzt (die eine synthetische Direktive
// statt der Roheingabe senden).
func (m *Model) sendAsMessage(text string) tea.Cmd {
m.entries = append(m.entries, entry{kind: entryUser, text: text})
m.turnActive = true
m.renderViewport()
return m.sendInput(text)
}
func (m *Model) sendInput(text string) tea.Cmd {
return func() tea.Msg {
select {
case m.in <- agent.Input{Text: text}:
case <-m.ctx.Done():
}
return nil
}
}
func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd {
switch e := ev.(type) {
case tool.TextDelta:
m.turnActive = true
m.hasLiveText = true
m.liveText.WriteString(e.Text)
m.renderViewport()
case tool.ThinkingDelta:
m.turnActive = true
m.hasLiveThink = true
m.liveThink.WriteString(e.Text)
m.renderViewport()
case tool.ToolCallStart:
m.commitLive()
m.addActiveTool(e.ID, e.Name)
m.entries = append(m.entries, entry{kind: entryToolCall, id: e.ID, text: renderToolCall(e.Name, e.Input)})
m.renderViewport()
case tool.ToolStream:
// Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins
// Transkript, das würde es bei langem Output unlesbar machen.
// Statuszeile zeigt bereits den Tool-Namen (seit ToolCallStart).
case tool.ToolCallOutput:
m.removeActiveTool(e.ID)
// Ersetzt den wartenden "→ tool"-Eintrag in-place statt einen
// zweiten Block anzuhängen — halbiert den vertikalen Platzbedarf
// pro Tool-Aufruf und hält die Position im Transkript stabil.
replaced := false
for i := len(m.entries) - 1; i >= 0; i-- {
if m.entries[i].kind == entryToolCall && m.entries[i].id == e.ID {
m.entries[i] = entry{kind: entryToolResult, id: e.ID, text: renderToolResult(e.Name, e.Result)}
replaced = true
break
}
}
if !replaced {
m.entries = append(m.entries, entry{kind: entryToolResult, id: e.ID, text: renderToolResult(e.Name, e.Result)})
}
m.renderViewport()
case tool.TurnDone:
m.commitLive()
m.renderViewport()
m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens
m.cacheReadTokens += e.Usage.CacheReadTokens
if e.Stop != llm.StopToolUse {
m.turnActive = false
if len(m.followupQueue) > 0 {
next := m.followupQueue[0]
m.followupQueue = m.followupQueue[1:]
return m.sendInput(next)
}
}
case tool.ErrorEvent:
m.commitLive()
m.turnActive = false
m.activeTools = nil
m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()})
m.renderViewport()
case tool.CompactionEvent:
m.entries = append(m.entries, entry{
kind: entrySystem,
text: fmt.Sprintf("compacted %d nodes into a %d-token summary", e.ReplacedNodes, e.SummaryTokens),
})
m.renderViewport()
}
return nil
}