Files
coder/cli/exp_agents_render.go
T
Michael Suchacz de30488b20 feat(cli): add experimental agents TUI (#24150)
> This PR was authored by Mux on behalf of Mike.

Adds `coder exp agents`, an interactive terminal UI for managing Coder
AI agent chats. Built with bubbletea/lipgloss/glamour, the TUI provides
parity with the web dashboard for chat management, model selection, and
real-time tool execution visibility.

## What it does

- **Chat list view**: tree-based navigation with nested subagent
expansion, search filtering, windowed scrolling, and pagination.
- **Active chat view**: viewport-based transcript with markdown
rendering, WebSocket streaming, and a text input composer for sending
messages.
- **Model picker overlay**: cached model catalog with fuzzy selection.
- **Diff drawer overlay**: git changes inspection with unified diff
rendering.
- **Tool call rendering**: humanized argument summaries, consecutive
duplicate collapsing, and status indicators.

## Key implementation details

- Session lifecycle uses a monotonic `chatGeneration` counter so async
responses from stale sessions are dropped on chat switch.
- Draft mode guards prevent duplicate chat creation on double-Enter.
- Error and loading states render inline without collapsing the TUI
chrome.
- Glamour renderer access is mutex-protected (not thread-safe).
- Intentional WebSocket close is distinguished from dropped connections
to prevent spurious reconnects.

## Testing

~220 unit tests covering rendering, state transitions, keyboard
dispatch, and edge cases. 4-scenario PTY-based E2E suite covers boot,
navigation, search, and direct chat open.

14 new files, ~7,400 lines added.
2026-04-17 12:16:06 +02:00

884 lines
26 KiB
Go

package cli
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/coder/coder/v2/codersdk"
)
const (
contextCompactionToolName = "context_compaction"
toolBlockIndent = " "
toolDetailIndent = " "
toolSummaryFallbackWidth = 48
pendingToolIcon = "○"
reasoningPrefix = "thinking: "
)
func compactTranscriptJSON(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var builder bytes.Buffer
if err := json.Compact(&builder, raw); err == nil {
return builder.String()
}
return string(raw)
}
func toolBaseName(name string) string {
name = strings.TrimSpace(name)
name = strings.TrimPrefix(name, "coder_")
name = strings.TrimPrefix(name, "github__")
return strings.Join(strings.Fields(name), " ")
}
func humanizeToolName(name string) string {
name = strings.ReplaceAll(toolBaseName(name), "_", " ")
name = strings.Join(strings.Fields(name), " ")
if name == "" {
return "tool"
}
return name
}
func normalizeToolName(name string) string {
if toolBaseName(name) == "" {
return ""
}
return strings.ReplaceAll(strings.ToLower(humanizeToolName(name)), " ", "_")
}
func summarizeToolContent(toolName, raw string, fields ...string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
if summary := toolObjectSummary(toolName, parsed); summary != "" {
return summary
}
if value := firstStringField(parsed, fields...); value != "" {
return strconv.Quote(value)
}
if value := firstShortStringValue(parsed); value != "" {
return strconv.Quote(value)
}
}
compact := compactTranscriptJSON(json.RawMessage(raw))
if compact == "" {
return ""
}
compactRunes := []rune(compact)
if len(compactRunes) <= toolSummaryFallbackWidth {
return compact
}
return string(compactRunes[:toolSummaryFallbackWidth-1]) + "…"
}
var toolArgsSummary = summarizeToolContent
func toolResultSummary(toolName, argsJSON, resultJSON string) string {
return cmp.Or(
summarizeToolContent(toolName, argsJSON),
summarizeToolContent(toolName, resultJSON),
"null",
)
}
func toolObjectSummary(toolName string, parsed any) string {
normalized := normalizeToolName(toolName)
switch {
case normalized == "execute" || normalized == "execute_command" || normalized == "run_command":
if command := firstStringField(parsed, "command", "cmd", "script", "input"); command != "" {
return strconv.Quote(command)
}
case strings.Contains(normalized, "read_file") || strings.Contains(normalized, "write_file") || strings.Contains(normalized, "delete_file") || strings.Contains(normalized, "stat_file"):
if path := firstStringField(parsed, "path", "file_path", "filename"); path != "" {
return "(" + path + ")"
}
case normalized == "get_pull_request":
owner := firstStringField(parsed, "owner")
repo := firstStringField(parsed, "repo", "repository")
switch {
case owner != "" && repo != "":
return "(" + owner + "/" + repo + ")"
case repo != "":
return "(" + repo + ")"
}
case strings.Contains(normalized, "workspace"):
if workspace := firstStringField(parsed, "workspace_name", "name", "workspace"); workspace != "" {
return "(" + workspace + ")"
}
}
return ""
}
func firstStringField(value any, keys ...string) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
for _, key := range keys {
fieldValue, ok := object[key]
if !ok {
continue
}
if text := firstShortStringValue(fieldValue); text != "" {
return text
}
}
return ""
}
func firstShortStringValue(value any) string {
switch typed := value.(type) {
case string:
trimmed := strings.Join(strings.Fields(strings.TrimSpace(typed)), " ")
if trimmed == "" {
return ""
}
return trimmed
case []any:
for _, item := range typed {
if text := firstShortStringValue(item); text != "" {
return text
}
}
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
if text := firstShortStringValue(typed[key]); text != "" {
return text
}
}
}
return ""
}
func toolDisplayLabel(toolName string, kind chatBlockKind, collapsedCount int) string {
label := humanizeToolName(toolName)
if collapsedCount <= 1 {
return label
}
switch kind {
case blockToolCall:
return label + "..."
case blockToolResult:
return fmt.Sprintf("%s (x%d)", label, collapsedCount)
default:
return label
}
}
func renderToolLine(styles tuiStyles, labelStyle lipgloss.Style, icon, label, summary string, width int) string {
label = sanitizeTerminalRenderableText(label)
summary = sanitizeTerminalRenderableText(summary)
header := toolBlockIndent + labelStyle.Render(icon) + " " + label
if summary == "" || width <= 0 {
return header
}
available := width - lipgloss.Width(header) - 1
preview := styles.truncate(summary, max(available, 0))
if preview == "" {
return header
}
return header + " " + styles.dimmedText.Render(preview)
}
func renderToolDetail(styles tuiStyles, label, value string, width int) string {
value = sanitizeTerminalRenderableText(value)
if strings.TrimSpace(value) == "" {
return ""
}
prefix := toolDetailIndent + label + ": "
wrapped := wrapPreservingNewlines(value, contentWidth(width, lipgloss.Width(prefix)))
lines := strings.Split(wrapped, "\n")
for i := range lines {
if i == 0 {
lines[i] = prefix + lines[i]
continue
}
lines[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + lines[i]
}
return styles.dimmedText.Render(strings.Join(lines, "\n"))
}
func renderExpandedToolBlock(styles tuiStyles, labelStyle lipgloss.Style, icon, toolName, args, result string, width int) string {
lines := []string{toolBlockIndent + labelStyle.Render(icon) + " " + humanizeToolName(toolName)}
if argsLine := renderToolDetail(styles, "args", args, width); argsLine != "" {
lines = append(lines, argsLine)
}
if resultLine := renderToolDetail(styles, "result", result, width); resultLine != "" {
lines = append(lines, resultLine)
}
return strings.Join(lines, "\n")
}
func toolResultIconAndStyle(styles tuiStyles, block chatBlock) (string, lipgloss.Style) {
if block.isError {
return "✗", styles.errorText
}
return "✓", styles.toolSuccess
}
func renderToolCallBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
return renderToolLine(
styles,
styles.toolPending,
pendingToolIcon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summarizeToolContent(block.toolName, block.args),
width,
)
}
func renderToolResultBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
icon, labelStyle := toolResultIconAndStyle(styles, block)
summary := summarizeToolContent(block.toolName, block.args)
if summary == "" && block.isError {
summary = summarizeToolContent("", block.result, "error", "message", "detail", "stderr")
}
if summary == "" {
summary = toolResultSummary(block.toolName, "", block.result)
}
return renderToolLine(
styles,
labelStyle,
icon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summary,
width,
)
}
func renderCompaction(styles tuiStyles, width int) string {
banner := styles.compaction.Render("🗜️ Context compacted")
if width <= 0 {
return banner
}
return lipgloss.PlaceHorizontal(width, lipgloss.Center, banner)
}
func contentWidth(width, inset int) int {
if width <= 0 {
return 80
}
return max(width-inset, 1)
}
func renderOverlayFrame(styles tuiStyles, width int, sections ...string) string {
sections = slices.DeleteFunc(sections, func(section string) bool { return section == "" })
return styles.overlayBorder.Width(contentWidth(width, 6)).Render(strings.Join(sections, "\n\n"))
}
func diffMetadataLines(diff codersdk.ChatDiffContents) []string {
var lines []string
if diff.Branch != nil && *diff.Branch != "" {
lines = append(lines, fmt.Sprintf("Branch: %s", *diff.Branch))
}
if diff.PullRequestURL != nil && *diff.PullRequestURL != "" {
lines = append(lines, fmt.Sprintf("PR: %s", *diff.PullRequestURL))
}
return lines
}
func renderChatDiffSummary(diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange) string {
lines := diffMetadataLines(diff)
if len(changes) == 0 {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "No changes detected.")
return strings.Join(lines, "\n")
}
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "Files changed:")
for _, change := range changes {
path := sanitizeTerminalRenderableText(change.FilePath)
if change.ChangeType == "renamed" && change.OldPath != nil && *change.OldPath != "" {
path = fmt.Sprintf("%s → %s", sanitizeTerminalRenderableText(*change.OldPath), path)
}
lines = append(lines, fmt.Sprintf(" %-8s %s", change.ChangeType, path))
}
return strings.Join(lines, "\n")
}
func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange, width, height int) string {
innerWidth := contentWidth(width, 6)
headerBits := []string{styles.title.Render("Diff")}
if meta := diffMetadataLines(diff); len(meta) > 0 {
headerBits = append(headerBits, styles.subtitle.Render(strings.Join(meta, " • ")))
}
summary := renderChatDiffSummary(diff, changes)
diffBody := sanitizeTerminalRenderableText(diff.Diff)
if strings.TrimSpace(diffBody) == "" {
diffBody = styles.dimmedText.Render("No diff contents.")
}
help := styles.helpText.Render("Esc to close")
overhead := countRenderedLines(strings.Join(headerBits, "\n")) + countRenderedLines(summary) + countRenderedLines(help) + 4
availableBodyLines := max(height-overhead, 0)
if height <= 0 {
availableBodyLines = 12
}
wrappedDiff := wrapPreservingNewlines(diffBody, innerWidth)
if availableBodyLines == 0 {
wrappedDiff = ""
} else {
wrappedDiff = clampLines(wrappedDiff, availableBodyLines)
}
return renderOverlayFrame(styles, width, strings.Join(headerBits, "\n"), summary, wrappedDiff, help)
}
func renderModelPicker(styles tuiStyles, catalog codersdk.ChatModelsResponse, selected string, cursor int, width, height int) string {
innerWidth := contentWidth(width, 6)
lines := []string{styles.title.Render("Select Model")}
cursorLine := 0
hasModels := false
flatIndex := 0
for _, provider := range catalog.Providers {
if len(provider.Models) == 0 {
continue
}
lines = append(lines, styles.subtitle.Render(provider.Provider))
if !provider.Available {
reason := string(provider.UnavailableReason)
if reason == "" {
reason = "unavailable"
}
lines = append(lines, " "+styles.dimmedText.Render(reason))
lines = append(lines, "")
continue
}
for _, model := range provider.Models {
hasModels = true
name := model.DisplayName
if strings.TrimSpace(name) == "" {
name = model.Model
}
marker := " "
if flatIndex == cursor {
marker = "> "
}
rowStyle := styles.normalItem
if model.ID == selected {
rowStyle = styles.selectedItem
}
lines = append(lines, marker+rowStyle.Render(styles.truncate(name, max(innerWidth-2, 0))))
if flatIndex == cursor {
cursorLine = len(lines) - 1
}
flatIndex++
}
lines = append(lines, "")
}
if !hasModels {
lines = append(lines, styles.dimmedText.Render("No models available."))
lines = append(lines, "")
}
help := styles.helpText.Render("Esc to close, Enter to select")
contentLines := lines
maxContentLines := max(height-countRenderedLines(help)-4, 1)
if height <= 0 {
maxContentLines = len(contentLines)
}
windowStart := 0
if cursorLine >= maxContentLines {
windowStart = cursorLine - maxContentLines + 1
}
maxWindowStart := max(len(contentLines)-maxContentLines, 0)
windowStart = min(windowStart, maxWindowStart)
windowEnd := min(windowStart+maxContentLines, len(contentLines))
content := append([]string(nil), contentLines[windowStart:windowEnd]...)
content = append(content, help)
return renderOverlayFrame(styles, width, strings.Join(content, "\n"))
}
func renderAskUserQuestion(styles tuiStyles, state *askUserQuestionState, width, height int) string {
if state == nil || len(state.Questions) == 0 {
return ""
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return ""
}
innerWidth := contentWidth(width, 6)
question := state.Questions[state.CurrentIndex]
sections := []string{styles.title.Render(fmt.Sprintf("Plan Question %d/%d", state.CurrentIndex+1, len(state.Questions)))}
if question.Header != "" {
sections = append(sections, styles.subtitle.Render(sanitizeTerminalRenderableText(question.Header)))
}
sections = append(sections, wrapPreservingNewlines(sanitizeTerminalRenderableText(question.Question), innerWidth))
if state.Submitting {
sections = append(sections, styles.dimmedText.Render("Submitting answers..."))
return renderOverlayFrame(styles, width, sections...)
}
optionLines := make([]string, 0, len(question.Options)+3)
for i, option := range question.Options {
label := strings.TrimSpace(sanitizeTerminalRenderableText(option.Label))
if label == "" {
label = "(empty option)"
}
label = styles.truncate(label, max(innerWidth-2, 0))
row := " " + label
if i == state.OptionCursor {
row = styles.selectedItem.Render("> " + label)
}
optionLines = append(optionLines, row)
}
otherLabel := styles.truncate("Other (type custom answer)", max(innerWidth-2, 0))
otherRow := " " + otherLabel
if state.OptionCursor == len(question.Options) {
otherRow = styles.selectedItem.Render("> " + otherLabel)
}
optionLines = append(optionLines, otherRow)
if state.OtherMode {
optionLines = append(optionLines, "", state.OtherInput.View())
}
sections = append(sections, strings.Join(optionLines, "\n"))
if state.Error != nil {
sections = append(sections, styles.errorText.Render(wrapPreservingNewlines(
"Error: "+sanitizeTerminalRenderableText(state.Error.Error()),
innerWidth,
)))
}
longHelpParts := []string{"↑/↓ navigate", "enter select"}
shortHelpParts := []string{"↑↓", "↵"}
compactHelpParts := []string{"↑↓", "↵"}
if state.CurrentIndex > 0 {
longHelpParts = append(longHelpParts, "←/h back")
shortHelpParts = append(shortHelpParts, "←/h")
compactHelpParts = append(compactHelpParts, "←")
}
if state.OtherMode {
longHelpParts = append(longHelpParts, "esc cancel input")
shortHelpParts = append(shortHelpParts, "esc input")
compactHelpParts = append(compactHelpParts, "esc")
}
sections = append(sections, styles.helpText.Render(fitHelpText(
innerWidth,
strings.Join(longHelpParts, " | "),
strings.Join(shortHelpParts, " │ "),
strings.Join(compactHelpParts, " "),
)))
_ = height
return renderOverlayFrame(styles, width, sections...)
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderChatBlocks(styles tuiStyles, blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool, composerFocused bool, width int, renderers ...*glamour.TermRenderer) string {
if len(blocks) == 0 {
return ""
}
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
activeSelection := -1
if !composerFocused {
activeSelection = selectedBlock
}
visibleIndices := collapseConsecutiveSameNameBlocks(blocks, activeSelection, expandedBlocks)
rendered := make([]string, 0, len(visibleIndices))
for _, index := range visibleIndices {
blockView := blocks[index].cachedRender
if blockView == "" ||
blocks[index].cachedWidth != width ||
blocks[index].cachedExpanded != expandedBlocks[index] ||
blocks[index].cachedCollapsedCount != blocks[index].collapsedCount {
blockView = renderBlock(styles, blocks[index], expandedBlocks[index], width, renderer)
blocks[index].cachedRender = blockView
blocks[index].cachedWidth = width
blocks[index].cachedExpanded = expandedBlocks[index]
blocks[index].cachedCollapsedCount = blocks[index].collapsedCount
}
if index == activeSelection {
blockView = styles.selectedBlock.Render(blockView)
}
rendered = append(rendered, blockView)
}
return strings.Join(rendered, "\n")
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderStatusBar(styles tuiStyles, chat *codersdk.Chat, status codersdk.ChatStatus, usage *codersdk.ChatMessageUsage, queueCount int, interrupting, reconnecting bool, width int) string {
_ = chat
parts := []string{styles.statusColor(status).Render(string(status))}
if usage != nil && usage.TotalTokens != nil && usage.ContextLimit != nil {
total := *usage.TotalTokens
limit := *usage.ContextLimit
if limit > 0 {
tokenText := fmt.Sprintf("tokens: %d/%d", total, limit)
pct := float64(total) / float64(limit) * 100
switch {
case pct > 95:
tokenText = styles.criticalText.Render(tokenText)
case pct > 80:
tokenText = styles.warningText.Render(tokenText)
}
parts = append(parts, tokenText)
}
}
if queueCount > 0 {
parts = append(parts, fmt.Sprintf("queued: %d", queueCount))
}
if interrupting {
parts = append(parts, styles.warningText.Render("interrupting…"))
}
if reconnecting {
parts = append(parts, styles.warningText.Render("reconnecting…"))
}
line := strings.Join(parts, styles.separator.Render(" │ "))
bar := styles.statusBar
if width > 0 {
bar = bar.MaxWidth(width)
}
return bar.Render(line)
}
func collapseConsecutiveSameNameBlocks(blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool) []int {
if len(blocks) == 0 {
return nil
}
for i := range blocks {
blocks[i].collapsedCount = 0
}
visibleIndices := make([]int, 0, len(blocks))
for i := 0; i < len(blocks); {
runEnd := i + 1
for runEnd < len(blocks) && canCollapseToolBlocks(blocks[i], blocks[runEnd]) {
runEnd++
}
if runEnd-i < 2 || hasExpandedToolBlock(expandedBlocks, i, runEnd) {
for j := i; j < runEnd; j++ {
visibleIndices = append(visibleIndices, j)
}
i = runEnd
continue
}
representative := i
if selectedBlock >= i && selectedBlock < runEnd {
representative = selectedBlock
}
blocks[representative].collapsedCount = runEnd - i
visibleIndices = append(visibleIndices, representative)
i = runEnd
}
return visibleIndices
}
func canCollapseToolBlocks(a, b chatBlock) bool {
if a.kind != b.kind {
return false
}
if a.kind != blockToolCall && a.kind != blockToolResult {
return false
}
if a.toolName != b.toolName {
return false
}
if a.kind == blockToolResult && a.isError != b.isError {
return false
}
if a.args != b.args || a.result != b.result {
return false
}
return true
}
func hasExpandedToolBlock(expandedBlocks map[int]bool, start, end int) bool {
for i := start; i < end; i++ {
if expandedBlocks[i] {
return true
}
}
return false
}
func messagesToBlocks(messages []codersdk.ChatMessage) []chatBlock {
blocks := make([]chatBlock, 0)
for _, message := range messages {
if message.Role == codersdk.ChatMessageRoleSystem {
continue
}
for _, part := range message.Content {
switch part.Type {
case codersdk.ChatMessagePartTypeText:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: part.Text})
case codersdk.ChatMessagePartTypeReasoning:
blocks = append(blocks, chatBlock{kind: blockReasoning, role: message.Role, text: part.Text})
case codersdk.ChatMessagePartTypeToolCall, codersdk.ChatMessagePartTypeToolResult:
block := chatBlock{role: message.Role, toolName: part.ToolName, toolID: part.ToolCallID}
switch {
case part.ToolName == contextCompactionToolName:
block.kind = blockCompaction
case part.Type == codersdk.ChatMessagePartTypeToolCall:
block.kind = blockToolCall
block.args = compactTranscriptJSON(part.Args)
default:
block.kind = blockToolResult
block.result = compactTranscriptJSON(part.Result)
block.isError = part.IsError
}
blocks = append(blocks, block)
case codersdk.ChatMessagePartTypeSource:
title := part.Title
if strings.TrimSpace(title) == "" {
title = part.URL
}
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)})
case codersdk.ChatMessagePartTypeFile:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[File: %s]", part.MediaType)})
case codersdk.ChatMessagePartTypeFileReference:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)})
}
}
}
return mergeConsecutiveToolBlocks(blocks)
}
func mergeToolResult(call, result chatBlock) chatBlock {
if call.toolName != "" {
result.toolName = call.toolName
}
result.kind = blockToolResult
result.toolID = call.toolID
result.args = call.args
return result
}
func mergeConsecutiveToolBlocks(blocks []chatBlock) []chatBlock {
if len(blocks) < 2 {
return blocks
}
merged := make([]chatBlock, 0, len(blocks))
for i := 0; i < len(blocks); i++ {
block := blocks[i]
if i+1 < len(blocks) {
next := blocks[i+1]
if block.kind == blockToolCall && next.kind == blockToolResult {
switch {
case block.toolID != "" && block.toolID == next.toolID:
merged = append(merged, mergeToolResult(block, next))
i++
continue
case block.toolID == "" && next.toolID == "" && block.toolName == next.toolName:
merged = append(merged, mergeToolResult(block, next))
i++
continue
}
}
}
merged = append(merged, block)
}
return merged
}
//nolint:revive // Signature keeps block expansion state explicit at the callsite.
func renderBlock(styles tuiStyles, block chatBlock, expanded bool, width int, renderers ...*glamour.TermRenderer) string {
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
switch block.kind {
case blockText:
switch block.role {
case codersdk.ChatMessageRoleUser:
return renderPrefixedBlock(styles.userMessage.Render("You: "), block.text, width)
case codersdk.ChatMessageRoleAssistant:
return renderAssistantMarkdown(styles, block.text, width, renderer)
case codersdk.ChatMessageRoleTool:
return styles.dimmedText.Render(wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width))
default:
return wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width)
}
case blockReasoning:
content := wrapPreservingNewlines(reasoningPrefix+sanitizeTerminalRenderableText(block.text), width)
if !expanded {
content = clampLines(content, 3)
}
return styles.reasoning.Render(content)
case blockToolCall:
if !expanded {
return renderToolCallBlock(styles, block, width)
}
return renderExpandedToolBlock(styles, styles.toolPending, pendingToolIcon, block.toolName, block.args, "", width)
case blockToolResult:
if !expanded {
return renderToolResultBlock(styles, block, width)
}
icon := "✓"
labelStyle := styles.toolSuccess
if block.isError {
icon = "✗"
labelStyle = styles.errorText
}
result := block.result
if strings.TrimSpace(result) == "" {
result = "null"
}
return renderExpandedToolBlock(styles, labelStyle, icon, block.toolName, block.args, result, width)
case blockCompaction:
return renderCompaction(styles, width)
default:
return ""
}
}
var (
fallbackMarkdownRenderers sync.Map
markdownRendererMu sync.Mutex
)
func getFallbackMarkdownRenderer(width int) *glamour.TermRenderer {
wrapWidth := contentWidth(width, 0)
if cachedRenderer, ok := fallbackMarkdownRenderers.Load(wrapWidth); ok {
renderer, ok := cachedRenderer.(*glamour.TermRenderer)
if ok {
return renderer
}
}
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
return nil
}
cachedRenderer, _ := fallbackMarkdownRenderers.LoadOrStore(wrapWidth, renderer)
storedRenderer, ok := cachedRenderer.(*glamour.TermRenderer)
if !ok {
return nil
}
return storedRenderer
}
func renderAssistantMarkdown(styles tuiStyles, text string, width int, renderers ...*glamour.TermRenderer) string {
text = sanitizeTerminalRenderableText(text)
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
if renderer == nil {
renderer = getFallbackMarkdownRenderer(width)
}
if renderer != nil {
markdownRendererMu.Lock()
rendered, err := renderer.Render(text)
markdownRendererMu.Unlock()
if err == nil {
trimmedRendered := strings.TrimRight(rendered, "\n")
if strings.TrimSpace(trimmedRendered) != "" || strings.TrimSpace(text) == "" {
return styles.assistantMsg.Render(trimmedRendered)
}
}
}
return styles.assistantMsg.Render(wrapPreservingNewlines(text, width))
}
func renderPrefixedBlock(prefix, body string, width int) string {
body = sanitizeTerminalRenderableText(body)
if strings.TrimSpace(body) == "" {
return prefix
}
prefixWidth := lipgloss.Width(prefix)
available := width - prefixWidth
if available <= 0 {
available = width
}
wrapped := wrapPreservingNewlines(body, available)
lines := strings.Split(wrapped, "\n")
if len(lines) == 0 {
return prefix
}
for i := 1; i < len(lines); i++ {
lines[i] = strings.Repeat(" ", max(prefixWidth, 0)) + lines[i]
}
return prefix + strings.Join(lines, "\n")
}
func wrapPreservingNewlines(text string, width int) string {
if width <= 0 {
return text
}
style := lipgloss.NewStyle().Width(width)
segments := strings.Split(text, "\n")
for i, segment := range segments {
segments[i] = strings.TrimRight(style.Render(segment), " ")
}
return strings.Join(segments, "\n")
}
func clampLines(text string, maxLines int) string {
return strings.Join(clampLineSlice(strings.Split(text, "\n"), maxLines), "\n")
}
func clampLineSlice(lines []string, maxLines int) []string {
if maxLines <= 0 {
return nil
}
if len(lines) <= maxLines {
return lines
}
clamped := append([]string(nil), lines[:maxLines]...)
clamped[maxLines-1] = stylesafeEllipsis(clamped[maxLines-1])
return clamped
}
func stylesafeEllipsis(line string) string {
trimmed := strings.TrimRight(line, " ")
if trimmed == "" {
return "…"
}
return trimmed + "…"
}
func countRenderedLines(text string) int {
if text == "" {
return 0
}
return strings.Count(text, "\n") + 1
}