mirror of
https://github.com/coder/coder.git
synced 2026-06-04 13:38:21 +00:00
de30488b20
> 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.
884 lines
26 KiB
Go
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
|
|
}
|