// 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 statusMsg string 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 — Enter sendet, Alt+Enter queued als Follow-up, /help für Kommandos" const askInputPlaceholder = "Antwort eingeben — Enter sendet" 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.statusMsg = "" 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 m.statusMsg != "" { state += ": " + m.statusMsg } } 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(renderEntry(e)) } if m.hasLiveThink { if len(m.entries) > 0 { b.WriteString("\n\n") } b.WriteString(styleThinking.Render(m.liveThink.String())) } if m.hasLiveText { if len(m.entries) > 0 || m.hasLiveThink { b.WriteString("\n\n") } b.WriteString(styleAssistant.Render(m.liveText.String())) } content := wrapForViewport(b.String(), m.viewport.Width) atBottom := m.viewport.AtBottom() m.viewport.SetContent(content) 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.statusMsg = e.Name m.entries = append(m.entries, entry{kind: entryToolCall, 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. m.statusMsg = e.ID + "…" case tool.ToolCallOutput: m.statusMsg = "" m.entries = append(m.entries, entry{kind: entryToolResult, text: renderToolResult(e.Name, e.Result)}) m.renderViewport() case tool.TurnDone: m.commitLive() m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens m.cacheReadTokens += e.Usage.CacheReadTokens if e.Stop != llm.StopToolUse { m.turnActive = false m.statusMsg = "" 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.statusMsg = "" 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 }