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

220 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tui
import (
"encoding/json"
"fmt"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"nub/internal/mcpc"
"nub/internal/tool"
"nub/internal/tool/builtin"
)
// wrapForViewport bricht Text auf die gegebene Breite um. bubbles/viewport
// bricht Inhalt nicht selbst um — es zeigt ihn nur an und lässt lange
// Zeilen horizontal überlaufen. lipgloss übernimmt den (ANSI-bewussten)
// Zeilenumbruch. width<=0 (z.B. vor dem ersten WindowSizeMsg) lässt den
// Text unverändert.
func wrapForViewport(content string, width int) string {
if width <= 0 {
return content
}
return lipgloss.NewStyle().Width(width).Render(content)
}
type entryKind int
const (
entryUser entryKind = iota
entryQueued
entryAssistant
entryThinking
entryToolCall
entryToolResult
entrySystem // Hintergrund-Ereignisse, nicht vom Nutzer angestoßen (z.B. Auto-Compaction)
entryCommand // Ausgabe eines Slash-Kommandos, das der Nutzer selbst getippt hat
entryError // echte Fehler (Loop-/Provider-Fehler, fehlgeschlagene Kommandos)
entryPermission // offene "ask"-Rückfrage, siehe permission.go
entryQuestion // offene Rückfrage des question-Tools, siehe question.go
)
type entry struct {
kind entryKind
id string // Tool-Call-ID; verknüpft entryToolCall mit dem späteren entryToolResult
text string // bereits gestylter, fertiger Text
}
// proseMaxWidth begrenzt die Zeilenlänge von Fließtext (Assistant, User,
// Thinking, ...) auf einen lesbaren Wert — bei breiten Terminals wird lange
// Prosa sonst unangenehm zu lesen. Tool-Output/Diffs bekommen die volle
// Viewport-Breite, da Tabellen/Diffs von der zusätzlichen Breite profitieren.
const proseMaxWidth = 100
// entryWrapWidth liefert die Wrap-Breite für einen Entry-Typ.
func entryWrapWidth(k entryKind, viewportWidth int) int {
if viewportWidth <= 0 || viewportWidth > proseMaxWidth {
switch k {
case entryAssistant, entryUser, entryQueued, entryThinking, entrySystem, entryCommand, entryError:
if viewportWidth > 0 {
return proseMaxWidth
}
}
}
return viewportWidth
}
var markdownRenderer, _ = glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um
)
func renderMarkdown(s string) string {
if markdownRenderer == nil || strings.TrimSpace(s) == "" {
return s
}
out, err := markdownRenderer.Render(s)
if err != nil {
return s
}
return strings.TrimRight(out, "\n")
}
func renderEntry(e entry) string {
switch e.kind {
case entryUser:
return styleUser.Render(" "+firstLine(e.text)) + tailLines(e.text)
case entryQueued:
return styleQueued.Render("⏳ "+firstLine(e.text)) + tailLines(e.text)
case entryAssistant:
return styleAssistant.Render(renderMarkdown(e.text))
case entryThinking:
return styleThinking.Render(e.text)
case entryToolCall:
return styleToolCall.Render(e.text)
case entryToolResult:
return e.text // bereits gestylt (ok/error-Farbe je nach Ergebnis)
case entrySystem:
return styleSystem.Render("· " + e.text)
case entryCommand:
return styleCommand.Render(e.text)
case entryError:
return styleError.Render("✗ " + e.text)
case entryPermission:
return e.text // bereits als umrandeter Block gerendert (renderPermissionRequest)
case entryQuestion:
return e.text // bereits als umrandeter Block gerendert (renderAskRequest)
default:
return e.text
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tailLines(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return "\n" + s[i+1:]
}
return ""
}
// renderToolCall zeigt Name + kompakte Eingabe.
func renderToolCall(name string, input []byte) string {
in := strings.TrimSpace(prettyJSON(input))
if len(in) > 300 {
in = in[:300] + "…"
}
return fmt.Sprintf("→ %s %s", name, in)
}
// renderToolResult wählt je nach ForUI-Typ eine spezifische Darstellung
// (Diff-Overlay für write/edit, kompakte Zusammenfassung sonst).
func renderToolResult(name string, res tool.Result) string {
status := styleToolOK.Render("✓")
if res.IsError {
status = styleToolErr.Render("✗")
}
header := fmt.Sprintf("%s %s", status, name)
switch ui := res.ForUI.(type) {
case builtin.DiffResult:
return header + "\n" + renderDiff(ui)
case builtin.BashResult:
out := res.ForModel
if len(out) > 2000 {
out = out[:2000] + "\n… [truncated in TUI, see ForModel für vollen Output]"
}
return header + fmt.Sprintf(" (exit %d)\n%s", ui.ExitCode, out)
case builtin.FileResult:
return header + fmt.Sprintf(" %s (%d Zeilen)", ui.Path, ui.Lines)
case builtin.GlobResult:
return header + fmt.Sprintf(" %d Treffer", len(ui.Paths))
case builtin.GrepResult:
return header + fmt.Sprintf(" %d Treffer", ui.Count)
case builtin.TodoResult:
return header + "\n" + renderTodos(ui)
case builtin.SkillResult:
return header + fmt.Sprintf(" %s geladen (%s)", ui.Name, ui.Dir)
case mcpc.Result:
return header + " " + res.ForModel
case builtin.QuestionResult:
return fmt.Sprintf("%s\nFrage: %s\nAntwort: %s", header, ui.Question, ui.Answer)
default:
out := res.ForModel
if len(out) > 1000 {
out = out[:1000] + "…"
}
return header + "\n" + out
}
}
func renderDiff(d builtin.DiffResult) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n", d.Path)
for _, line := range d.Lines {
switch line.Op {
case builtin.DiffAdd:
b.WriteString(styleDiffAdd.Render("+ " + line.Text))
case builtin.DiffRemove:
b.WriteString(styleDiffDel.Render("- " + line.Text))
default:
b.WriteString(" " + line.Text)
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func renderTodos(t builtin.TodoResult) string {
var b strings.Builder
for _, it := range t.Items {
mark := " "
switch it.Status {
case "in_progress":
mark = "~"
case "completed":
mark = "x"
}
fmt.Fprintf(&b, "[%s] %s\n", mark, it.Content)
}
return strings.TrimRight(b.String(), "\n")
}
func prettyJSON(raw json.RawMessage) string {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return string(raw)
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return string(raw)
}
return string(out)
}