nub/internal/ui/tui/render.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

200 lines
5.4 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
text string // bereits gestylter, fertiger Text
}
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)
}