Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 61 additions & 67 deletions cmd/entire/cli/review/tui_detail.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Package review — see env.go for package-level rationale.
//
// tui_detail.go provides detailView, the pure-function renderer for the
// alt-screen drill-in view. It renders one agent's live event buffer with
// header/footer chrome and pads to exactly termHeight lines so every frame
// has the same line count (avoids ghost rows in the Bubble Tea alt-screen
// frame diff).
// tui_detail.go provides the alt-screen drill-in renderer. The body content is
// produced by [eventLines] (one or more wrapped lines per event) and fed into
// a bubbles/v2/viewport on [reviewTUIModel]; this file's [detailFrame] is the
// pure-function chrome (header + body + footer) that wraps the viewport's
// pre-rendered output and pads to exactly termHeight lines so every frame has
// the same line count (avoids ghost rows in Bubble Tea's alt-screen diff).
package review

import (
Expand All @@ -14,16 +15,22 @@ import (
reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types"
)

// eventLine converts a single Event to a single display line for the detail
// view. The line is stripped of control sequences and truncated to maxWidth
// display cells.
func eventLine(ev reviewtypes.Event, maxWidth int) string {
// eventLines converts a single Event to one or more display lines for the
// detail view, wrapped to maxWidth display cells. AssistantText preserves
// embedded '\n' as paragraph breaks; other event types render as a single
// sanitized line that wraps only on width overflow.
func eventLines(ev reviewtypes.Event, maxWidth int) []string {
if maxWidth <= 0 {
return nil
}
var raw string
switch e := ev.(type) {
case reviewtypes.Started:
raw = "[started]"
case reviewtypes.AssistantText:
raw = e.Text
// AssistantText is the only event that can contain meaningful
// multi-line content; wrapDisplayWidth honors embedded newlines.
return wrapDisplayWidth(e.Text, maxWidth)
case reviewtypes.ToolCall:
raw = fmt.Sprintf("[tool: %s] %s", e.Name, e.Args)
case reviewtypes.Tokens:
Expand All @@ -44,100 +51,87 @@ func eventLine(ev reviewtypes.Event, maxWidth int) string {
raw = "[unknown event]"
}

return truncateDisplayWidth(sanitizeDisplayText(raw), maxWidth)
return wrapDisplayWidth(raw, maxWidth)
}

// detailView renders the alt-screen drill-in for one agent. row is the agentRow
// being inspected. termWidth/termHeight come from WindowSizeMsg.
// buildEventLines returns every wrapped body line for the supplied event
// buffer, in order. The result is suitable for feeding into a viewport via
// SetContentLines.
func buildEventLines(buffer []reviewtypes.Event, maxWidth int) []string {
if len(buffer) == 0 || maxWidth <= 0 {
return nil
}
out := make([]string, 0, len(buffer))
for _, ev := range buffer {
out = append(out, eventLines(ev, maxWidth)...)
}
return out
}

// detailFrame renders the alt-screen drill-in chrome around a body string. The
// body is the viewport's already-rendered view (already clipped to bodyHeight
// lines by the viewport itself). detailFrame adds:
//
// Rendering:
// 1. Header line: "─── <name> (<n> events) ─────────────" (filled to termWidth)
// 2. Body: events from row.buffer scrolled to detailScroll, one line each,
// sanitized and truncated to termWidth display cells.
// 3. Footer line: "←/→ switch agent · Esc back · ↑/↓ scroll"
// 2. Body: trimmed/padded to exactly bodyHeight lines, each padded to termWidth.
// 3. Footer line: "←/→ switch agent · Esc back · scroll: PgUp/PgDn/↑/↓/Home/End"
//
// CRITICAL: the rendered string is padded to exactly termHeight lines so every
// frame has the same line count. Bubble Tea's alt-screen frame diff leaves ghost
// rows if the line count varies between frames.
func detailView(row agentRow, detailScroll, termWidth, termHeight int) string {
// CRITICAL: the rendered string is exactly termHeight lines. Bubble Tea's
// alt-screen diff leaves ghost rows if the line count varies between frames.
func detailFrame(row agentRow, body string, termWidth, termHeight int) string {
if termWidth < 1 {
termWidth = 80
}
if termHeight < 3 {
termHeight = 3
}

// Reserve 1 line for header, 1 for footer; the body fills the rest.
bodyHeight := termHeight - 2
if bodyHeight < 0 {
bodyHeight = 0
}

// 1. Header line.
headerContent := fmt.Sprintf("─── %s (%d events) ", sanitizeDisplayText(row.name), len(row.buffer))
header := padDisplayWidthWith(headerContent, termWidth, "─")

// 2. Body lines.
lines := buildBodyLines(row.buffer, detailScroll, bodyHeight, termWidth)

// Pad body to exactly bodyHeight lines.
for len(lines) < bodyHeight {
lines = append(lines, strings.Repeat(" ", termWidth))
}
// Normalize the viewport body to exactly bodyHeight lines, each padded to
// termWidth so frame width is stable.
bodyLines := splitBodyToHeight(body, bodyHeight, termWidth)

// 3. Footer line.
footerText := "←/→ switch agent · Esc back · ↑/↓ scroll"
footerText := "←/→ switch agent · Esc back · scroll: PgUp/PgDn/↑/↓/Home/End"
footer := padDisplayWidth(footerText, termWidth)

// Assemble: header + body + footer = termHeight lines total.
var b strings.Builder
b.WriteString(header)
b.WriteString("\n")
for _, line := range lines {
for _, line := range bodyLines {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString(footer)
// No trailing newline after footer — the caller (View) adds its own.

return b.String()
}

// buildBodyLines computes the visible body lines for the detail view.
// It takes the full event buffer, a scroll offset, the maximum number of lines
// to show, and the column width. Returns at most bodyHeight lines.
func buildBodyLines(buffer []reviewtypes.Event, scroll, bodyHeight, termWidth int) []string {
if len(buffer) == 0 || bodyHeight <= 0 {
// splitBodyToHeight normalizes a multi-line body string to exactly bodyHeight
// lines, each truncated and padded to termWidth. Missing lines are padded
// with spaces. The bodyHeight cap is a defensive guard: viewport.View()
// should already clip to its Height(), so the overflow-truncation path is
// not expected to trigger in normal use.
func splitBodyToHeight(body string, bodyHeight, termWidth int) []string {
if bodyHeight <= 0 {
return nil
}

// Clamp scroll to valid range.
if scroll < 0 {
scroll = 0
}
if scroll >= len(buffer) {
scroll = len(buffer) - 1
}

// Determine window: scroll is the index of the LAST visible line so the
// user sees the most-recent events when auto-scrolling. Work backwards.
end := scroll + 1 // exclusive upper bound
start := end - bodyHeight
if start < 0 {
start = 0
var raw []string
if body != "" {
raw = strings.Split(strings.TrimRight(body, "\n"), "\n")
}
// Clamp end to buffer length.
if end > len(buffer) {
end = len(buffer)
start = end - bodyHeight
if start < 0 {
start = 0
lines := make([]string, 0, bodyHeight)
for i := range bodyHeight {
if i < len(raw) {
lines = append(lines, padDisplayWidth(raw[i], termWidth))
} else {
lines = append(lines, strings.Repeat(" ", termWidth))
}
}

lines := make([]string, 0, end-start)
for i := start; i < end; i++ {
lines = append(lines, eventLine(buffer[i], termWidth))
}
return lines
}
Loading
Loading