first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
package tui
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/x/ansi"
"github.com/smallnest/pigo/internal/cli/ui"
)
// This file renders finalized assistant turns as Markdown inside the TUI
// transcript (fix #3, mirroring the REPL's ui.RenderMarkdown). The REPL renders
// once at turn-end because Markdown can only be laid out when the whole block is
// known; the transcript does the same — only a finalized assistant block is
// passed through here, never the still-streaming one.
//
// Unlike the REPL's shared renderer (WithWordWrap(0), which relies on the raw
// terminal to soft-wrap), the transcript lives inside a fixed-width viewport
// that does NOT soft-wrap, so we must wrap the Markdown to the content width
// ourselves. Renderers are therefore cached per width and rebuilt when the width
// changes (a resize), which is rare enough that the rebuild cost is negligible.
var (
mdMu sync.Mutex
mdCache = map[int]*glamour.TermRenderer{}
// mdDark selects the glamour style: a dark palette when true (the default,
// matching most terminals), a light palette when false. It is set once from
// the terminal's real background via SetMarkdownDark and never queried at
// render time — see the comment there.
mdDark = true
)
// SetMarkdownDark records whether the terminal has a dark background and drops
// the renderer cache so the next render rebuilds with the matching style.
//
// This is the fix for the escape-sequence leak into the input box: glamour's
// WithAutoStyle() detects the palette by issuing its OWN synchronous OSC 11
// background-color query and reading the reply straight from the tty. Under the
// alt-screen, bubbletea already owns the input reader, so that reply
// (\x1b]11;rgb:…\x07) races with — and is swallowed by — bubbletea's parser,
// which then leaks the unparsed tail (e.g. "1;rgb:0000/0000/0000" plus a stray
// SGR mouse report) into the textarea as literal text. We instead let bubbletea
// detect the background the parser-safe way (RequestBackgroundColor →
// BackgroundColorMsg) and feed the result here, then build glamour with a fixed
// WithStandardStyle so it never touches the terminal.
func SetMarkdownDark(dark bool) {
mdMu.Lock()
defer mdMu.Unlock()
if dark == mdDark {
return
}
mdDark = dark
mdCache = map[int]*glamour.TermRenderer{}
}
// rendererFor returns a glamour renderer that word-wraps to width columns,
// building and caching one per distinct width. A build failure caches nothing
// and returns nil so callers fall back to the raw source. The style is fixed
// (WithStandardStyle) rather than auto-detected, so building a renderer never
// queries the terminal.
func rendererFor(width int) *glamour.TermRenderer {
mdMu.Lock()
defer mdMu.Unlock()
if r, ok := mdCache[width]; ok {
return r
}
wrap := width
if wrap < 0 {
wrap = 0
}
style := "dark"
if !mdDark {
style = "light"
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style),
glamour.WithWordWrap(wrap),
)
if err != nil {
return nil
}
mdCache[width] = r
return r
}
// renderMarkdown returns src rendered as styled terminal Markdown wrapped to
// width columns. It is gated exactly like the REPL's renderer: when output is
// not an interactive terminal (pipes, tests) the raw source is returned so
// golden tests and machine consumers are unaffected. A nil/broken renderer or a
// render error also returns the raw source, so content is never dropped. The
// trailing newline glamour appends is trimmed so the block joins cleanly with
// its neighbors in the transcript.
func renderMarkdown(src string, width int) string {
if !ui.Enabled() {
return src
}
if strings.TrimSpace(src) == "" {
return src
}
r := rendererFor(width)
if r == nil {
return src
}
out, err := r.Render(src)
if err != nil {
return src
}
// Glamour word-wraps prose to width, but its document margin and
// non-wrapping elements (code blocks, tables) can still emit lines wider than
// the content column. The transcript viewport does not clip horizontally, so
// an over-wide line would spill into (and visually erase) the persistent
// scrollbar column on its right. Hard-wrap the rendered output — ANSI- and
// wide-char-aware — so every line fits within width and the scrollbar stays
// put. Prose already within width is untouched.
trimmed := strings.Trim(out, "\n")
if width > 0 {
trimmed = ansi.Hardwrap(trimmed, width, false)
}
return trimmed
}