560 lines
14 KiB
Go
560 lines
14 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
|
|
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
|
|
}
|