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) }