Files
2026-08-14 23:41:57 +08:00

405 lines
14 KiB
Go

package tui
import (
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/agentcore"
)
// This file implements the scrolling transcript region of the full-screen TUI
// (US-005, SPEC 5.1 transcript, FR-5/FR-10). The transcript owns a
// viewport.Model and an ordered list of rendered blocks (user / assistant /
// system turns). Streaming assistant text arrives as textDeltaMsg values that
// append to the current assistant block; turnEndMsg finalizes it. Content is
// re-flowed through the viewport with theme.WrapToWidth at the live width so CJK
// and emoji never split mid-rune. Tool cards are a later node (#389); this file
// leaves a clean seam (system lines) without building cards.
// blockRole distinguishes the three transcript block kinds so each renders with
// its own theme style.
type blockRole int
const (
roleUser blockRole = iota
roleAssistant
roleSystem
roleTool
// roleBanner is the startup logo + config splash. Its text is pre-rendered
// (already colored, already laid out) and emitted verbatim, so reflow neither
// wraps it nor overrides its colors with a role style.
roleBanner
)
// transcriptBlock is one rendered turn in the transcript. text is the raw
// (unstyled, unwrapped) message body; the role selects the theme style and any
// prefix applied at render time. For roleTool blocks text is unused and card
// points at the live tool card (#389); the pointer lets a later toolEndMsg /
// Ctrl+O mutate the card in place and have it re-render on the next reflow.
type transcriptBlock struct {
role blockRole
text string
card *toolCard
}
// transcript is the scrolling message log. It wraps a viewport.Model and keeps
// the source blocks so it can re-flow on width changes. activeAssistant indexes
// the assistant block currently receiving streaming deltas, or -1 when no turn
// is streaming.
type transcript struct {
vp viewport.Model
theme Theme
// totalWidth is the full width the transcript may occupy (terminal columns
// minus any chrome the model reserves). width (below) is the content width
// the blocks actually wrap to: it equals totalWidth when the content fits, or
// totalWidth-1 when it overflows and a scrollbar column must be held back.
// reflow recomputes width from totalWidth on every content change, so the bar
// column appears/disappears correctly even as a run streams in new lines.
totalWidth int
// width is the content width (terminal columns) the blocks wrap to. It is
// separate from the viewport's own width so reflow measurements stay stable
// even before the first size message.
width int
blocks []transcriptBlock
activeAssistant int
// follow is the stick-to-bottom intent: while true, every reflow snaps the
// viewport to the newest line so streamed output stays visible. It is set
// when the user submits a turn and cleared when they scroll up to read
// history (re-armed when they scroll back to the bottom). Tracking intent
// explicitly — rather than sampling viewport.AtBottom() inside reflow — keeps
// auto-scroll correct across height changes (setSize resizes the viewport
// before reflow runs, which would make an AtBottom() sample read false).
follow bool
}
// newTranscript builds an empty transcript with the given theme. The viewport
// starts zero-sized; the model drives setSize from the first tea.WindowSizeMsg.
func newTranscript(theme Theme) transcript {
vp := viewport.New()
return transcript{
vp: vp,
theme: theme,
activeAssistant: -1,
}
}
// setSize resizes the transcript's viewport and re-flows the blocks to the new
// width. A non-positive dimension is clamped to zero so the viewport never sees
// a negative extent. width is the total space available; reflow decides whether
// to spend one column on the scrollbar based on whether the content overflows.
func (t *transcript) setSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
t.totalWidth = width
t.vp.SetHeight(height)
t.reflow()
}
// addUser appends a user turn and closes any streaming assistant block, then
// re-flows. Submitting a prompt is an explicit action where the user always
// wants to see their new turn and the response that follows, so it re-arms
// follow: the viewport snaps to the bottom even if the user had scrolled up
// (e.g. reading the startup banner) — otherwise the streamed reply would
// accumulate off-screen and look like nothing happened. Subsequent streaming
// deltas keep the bottom via follow, which the user can pause by scrolling up.
func (t *transcript) addUser(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleUser, text: text})
t.activeAssistant = -1
t.follow = true
t.reflow()
}
// addSystem appends a system / meta notice (used for run lifecycle and other
// inline notes).
func (t *transcript) addSystem(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleSystem, text: text})
t.reflow()
}
// addBanner appends a pre-rendered splash block (startup logo + config). It is
// emitted verbatim by renderBlock, so its colors and horizontal layout survive
// reflow untouched.
func (t *transcript) addBanner(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleBanner, text: text})
t.reflow()
}
// addToolCard appends a rich tool-call card (#389) as an ordered block so it
// renders inline in the transcript. The card is held by pointer, so a later
// state change (toolEndMsg) or expand toggle (Ctrl+O) followed by reflow
// re-renders it in place.
func (t *transcript) addToolCard(c *toolCard) {
t.blocks = append(t.blocks, transcriptBlock{role: roleTool, card: c})
t.reflow()
}
// appendDelta grows the current assistant block by delta, creating the block on
// the first delta of a turn. The re-flow auto-sticks to the bottom when the user
// has not scrolled up.
func (t *transcript) appendDelta(delta string) {
if t.activeAssistant < 0 {
t.blocks = append(t.blocks, transcriptBlock{role: roleAssistant})
t.activeAssistant = len(t.blocks) - 1
}
t.blocks[t.activeAssistant].text += delta
t.reflow()
}
// finalizeTurn closes the streaming assistant block. When the final message
// carries text it becomes the block's authoritative body (covering turns that
// arrive without incremental deltas); otherwise the accumulated deltas stand.
func (t *transcript) finalizeTurn(msg agentcore.AssistantMessage) {
text := agentcore.ContentToText(msg.Content)
if t.activeAssistant >= 0 {
if text != "" {
t.blocks[t.activeAssistant].text = text
}
} else if text != "" {
t.blocks = append(t.blocks, transcriptBlock{role: roleAssistant, text: text})
}
t.activeAssistant = -1
t.reflow()
}
// update forwards a message (typically a key press or scroll) to the viewport so
// PgUp/PgDn/arrow scrolling works, then re-syncs the follow intent: scrolling up
// off the bottom pauses auto-scroll, and scrolling back to the bottom re-arms it.
func (t *transcript) update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
t.vp, cmd = t.vp.Update(msg)
t.follow = t.vp.AtBottom()
return cmd
}
// scrollToRow positions the viewport so the scrollbar thumb aligns with the
// given viewport row y (0-based). It is the inverse of the thumb-position math
// in scrollbar(): pressing or dragging on row y maps that row to the matching
// scroll offset, so clicking the gutter jumps there and dragging the thumb
// tracks the cursor. It is a no-op when the content fits (nothing to scroll).
func (t *transcript) scrollToRow(y int) {
h := t.vp.Height()
if h <= 0 {
return
}
total := t.vp.TotalLineCount()
if total <= h {
return
}
thumb := h * h / total
if thumb < 2 {
thumb = 2
}
if thumb > h {
thumb = h
}
span := h - thumb // rows the thumb top can occupy
if span <= 0 {
return
}
// Center the grab on the thumb: aim its top at y minus half its body so the
// cursor sits roughly mid-thumb, then clamp into the track.
top := y - thumb/2
if top < 0 {
top = 0
}
if top > span {
top = span
}
maxOff := total - h
t.vp.SetYOffset(top * maxOff / span)
t.follow = t.vp.AtBottom()
}
// viewportHeight reports the number of visible transcript rows, so the model can
// tell whether a mouse Y falls within the scrollable region.
func (t transcript) viewportHeight() int { return t.vp.Height() }
// overflowing reports whether the transcript has more content than fits in the
// viewport, i.e. there is history to scroll. relayout uses this to reserve the
// scrollbar column only when scrolling is possible, and view uses it to decide
// whether to attach the thumb at all.
func (t transcript) overflowing() bool {
return t.vp.Height() > 0 && t.vp.TotalLineCount() > t.vp.Height()
}
// view renders the current visible slice of the transcript. When the content
// overflows the viewport a one-column vertical scrollbar is drawn down the right
// edge (FR-10): each viewport row is normalized to exactly the content width
// before the scrollbar cell is appended, so the bar sits flush against the
// terminal's right edge and a dangling SGR from Markdown rendering can never
// bleed into (and hide) the bar column. When everything fits there is nothing to
// scroll, so no bar is drawn and the viewport uses the full width (relayout
// releases the reserved column in that case).
func (t transcript) view() string {
if !t.overflowing() {
return t.vp.View()
}
bar := strings.Split(t.scrollbar(), "\n")
body := strings.Split(t.vp.View(), "\n")
// Fit every body line to exactly t.width columns (ANSI-aware pad/truncate),
// terminating any open style so the bar cell renders on a clean slate.
fit := lipgloss.NewStyle().Width(t.width).MaxWidth(t.width)
var b strings.Builder
for i := 0; i < len(bar); i++ {
if i > 0 {
b.WriteByte('\n')
}
line := ""
if i < len(body) {
line = body[i]
}
if t.width > 0 {
b.WriteString(fit.Render(line))
}
b.WriteString(bar[i])
}
return b.String()
}
// scrollbar renders the one-column vertical scrollbar the height of the
// viewport. A proportional thumb marks the visible window and its position marks
// the scroll offset, so scrolling up through history moves the thumb; the
// remaining rows draw a thin groove (│). The thumb is drawn as a capsule like
// the macOS system scrollbar: a lower-half block ▄ caps the top and an upper-half
// block ▀ caps the bottom (their filled halves sit on the inner edges so the
// outer ends taper to rounded), with the full block █ filling the body rows
// between the caps. The thumb is never shorter than three rows, so the capsule
// always shows a body between its two rounded caps rather than collapsing to a
// flat blob. When the content fits (no overflow) the capsule fills the full
// height.
func (t transcript) scrollbar() string {
h := t.vp.Height()
if h <= 0 {
return ""
}
total := t.vp.TotalLineCount()
thumb := h
pos := 0
if total > h {
thumb = h * h / total
// Keep the capsule shape (rounded cap + body + rounded cap) by never
// letting the thumb shrink below three rows; clamp down to the viewport
// height when it is shorter than that.
if thumb < 3 {
thumb = 3
}
if thumb > h {
thumb = h
}
maxOff := total - h
off := t.vp.YOffset()
if off > maxOff {
off = maxOff
}
if maxOff > 0 {
pos = off * (h - thumb) / maxOff
}
}
var b strings.Builder
for i := 0; i < h; i++ {
if i > 0 {
b.WriteByte('\n')
}
switch {
case i < pos || i >= pos+thumb:
b.WriteString(t.theme.ScrollTrack.Render("│"))
case thumb >= 2 && i == pos:
b.WriteString(t.theme.ScrollThumb.Render("▄"))
case thumb >= 2 && i == pos+thumb-1:
b.WriteString(t.theme.ScrollThumb.Render("▀"))
default:
b.WriteString(t.theme.ScrollThumb.Render("█"))
}
}
return b.String()
}
// reflow re-renders every block to the current width and pushes the joined
// content into the viewport. When the follow intent is set it snaps to the
// bottom so new content auto-scrolls; otherwise the offset is preserved so
// reading history is not interrupted. follow is tracked in update/scrollToRow
// (user scroll) and addUser (new turn) rather than sampled here, because setSize
// resizes the viewport before reflow runs and an AtBottom() sample would misread.
//
// Width is decided here rather than in setSize so it stays correct as a run
// streams in new lines (which reach reflow via appendDelta/finalizeTurn, not
// setSize): the blocks are first laid out at the full width, and only if that
// overflows the viewport is one column handed back to the scrollbar and the
// blocks re-laid at totalWidth-1. When the content fits, the transcript keeps
// the full width and view() draws no bar.
func (t *transcript) reflow() {
t.width = t.totalWidth
t.vp.SetWidth(t.width)
t.vp.SetContent(t.renderAll())
// A narrower width never reduces the line count, so if the full-width layout
// already overflows it still overflows at totalWidth-1: reserve the scrollbar
// column and re-lay the blocks so the body never sits under the bar.
if t.totalWidth > 0 && t.vp.TotalLineCount() > t.vp.Height() {
t.width = t.totalWidth - 1
t.vp.SetWidth(t.width)
t.vp.SetContent(t.renderAll())
}
if t.follow {
t.vp.GotoBottom()
}
}
// renderAll joins every block, rendered to the current content width, into the
// transcript body string. Consecutive turns are separated by a blank line before
// a new user turn so requests read as visually distinct.
func (t *transcript) renderAll() string {
var b strings.Builder
for i, blk := range t.blocks {
if i > 0 {
b.WriteByte('\n')
if blk.role == roleUser {
b.WriteByte('\n')
}
}
b.WriteString(t.renderBlock(blk, i == t.activeAssistant))
}
return b.String()
}
// renderBlock wraps a block's text to the content width and applies the role's
// theme style. Wrapping happens on the raw text (measured in display columns via
// WrapToWidth) before styling so ANSI escapes never confuse the width math and
// no double-width rune is split. A finalized assistant block is rendered as
// Markdown (fix #3, mirroring the REPL's turn-end render); the still-streaming
// block (streaming==true) stays plain text because Markdown can only be laid out
// once the whole block is known.
func (t transcript) renderBlock(blk transcriptBlock, streaming bool) string {
if blk.role == roleTool && blk.card != nil {
return blk.card.render(t.theme, t.width)
}
switch blk.role {
case roleBanner:
return blk.text
case roleUser:
return t.theme.User.Render(WrapToWidth(blk.text, t.width))
case roleSystem:
return t.theme.System.Render(WrapToWidth(blk.text, t.width))
default:
if streaming {
return t.theme.Assistant.Render(WrapToWidth(blk.text, t.width))
}
return renderMarkdown(blk.text, t.width)
}
}