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