Files
coder/cli/agents_render.go
T
Dean Sheather e57525002c chore: remove agents experiment flag and mark feature as beta (#24432)
Remove the `ExperimentAgents` feature flag so the Agents feature is
always available without requiring `--experiments=agents`. The feature
is now in beta.

Existing deployments that still pass `--experiments=agents` will get a
harmless "ignoring unknown experiment" warning on startup.

### Changes

**Backend:**
- Remove `RequireExperimentWithDevBypass` middleware from chat and MCP
server routes
- Always include `AgentsAccessRole` in assignable site roles (later
refactored to org-scoped on main; rebase keeps that)
- Always set `AgentsTabVisible = true`, then drop the entire dead
`AgentsTabVisible` metadata pipeline (Go htmlState field,
populateHTMLState goroutine, HTML meta tag, useEmbeddedMetadata
registration, mock); no production consumer reads it. `AgentsNavItem`
already gates on `permissions.createChat`.
- Make `blob:` CSP `img-src` addition unconditional
- Remove `ExperimentAgents` constant, `DisplayName` case, and
`ExperimentsKnown` entry

**CLI:**
- Graduate the agents TUI from `coder exp agents` to `coder agents`
(moved from `AGPLExperimental()` to `CoreSubcommands()`)
- Drop the `agent` alias so it does not collide with the hidden
workspace-agent command
- Rename implementation files `cli/exp_agents_*.go` -> `cli/agents_*.go`
and internal identifiers (`expChatsTUIModel` -> `chatsTUIModel`,
`newExpChatsTUIModel` -> `newChatsTUIModel`, `setupExpAgentsBackend` ->
`setupAgentsBackend`, `startExpAgentsSession` -> `startAgentsSession`,
`expAgentsPtr` -> `agentsPtr`, `expAgentsSession` -> `agentsSession`,
`TestExpAgents*` -> `TestAgents*`). `expClient` (the
`*codersdk.ExperimentalClient` local) is kept; `coderd/exp_chats*.go`
and other still-experimental `cli/exp_*.go` commands are intentionally
untouched.

**Frontend:**
- Remove experiment check from `AgentsNavItem` - render when
`canCreateChat` is true
- Remove `agentsEnabled` experiment check from `WorkspacesPage`, then
gate `chatsByWorkspace` on `permissions.createChat` so users without
chat access don't trigger the per-page DB query (Copilot review
feedback)
- Add `FeatureStageBadge` (beta) next to the Coder logo in the Agents
sidebar (desktop + mobile)

**Docs:**
- Remove experiment flag setup instructions from `early-access.md` and
`getting-started.md` (and rename `early-access.md`'s "Enable Coder
Agents" heading to "Set up Coder Agents", since there is no enablement
step left)
- Update `chats-api.md` and `getting-started.md`'s Chats API note to say
"beta" instead of "experimental"
- `docs/manifest.json`: drop "experimental" from the Chats API sidebar
description
- `make gen` regenerated `docs/reference/cli/agents.md` and the CLI
index
- `scripts/check_emdash.sh`: exclude `cli/testdata/*.golden` and
`enterprise/cli/testdata/*.golden` from the new repo-wide emdash lint,
since serpent emits emdash borders in every generated `--help` golden
file

**Tests:**
- Remove `ExperimentAgents` setup from all test files (14 occurrences
across 7 files)
- Update stale "with the agents experiment" comments in
`coderd/x/chatd/integration_test.go` and `coderd/mcp_test.go`


<img width="1185" height="900" alt="image"
src="https://github.com/user-attachments/assets/b420bc8f-41d6-42c6-abd8-ad572533d651"
/>


> 🤖 Generated by Coder Agents
2026-05-01 01:49:00 +10:00

1252 lines
40 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 parseChatGitChangesFromUnifiedDiff(diff codersdk.ChatDiffContents) []codersdk.ChatGitChange {
rawDiff := sanitizeTerminalRenderableText(diff.Diff)
if strings.TrimSpace(rawDiff) == "" {
return nil
}
var (
changes []codersdk.ChatGitChange
current *codersdk.ChatGitChange
currentAdditions int
currentDeletions int
inHunk bool
)
flush := func() {
if current == nil {
return
}
if current.FilePath == "" {
current = nil
currentAdditions = 0
currentDeletions = 0
return
}
if currentAdditions > 0 || currentDeletions > 0 {
stats := make([]string, 0, 2)
if currentAdditions > 0 {
stats = append(stats, fmt.Sprintf("+%d", currentAdditions))
}
if currentDeletions > 0 {
stats = append(stats, fmt.Sprintf("-%d", currentDeletions))
}
summary := strings.Join(stats, " ")
current.DiffSummary = &summary
}
changes = append(changes, *current)
current = nil
currentAdditions = 0
currentDeletions = 0
}
for line := range strings.SplitSeq(rawDiff, "\n") {
switch {
case strings.HasPrefix(line, "diff --git "):
flush()
inHunk = false
// parseUnifiedDiffHeaderPaths may return ("", "", false) when
// the unquoted header form is ambiguous, such as a rename with
// spaces in the paths. We still want to start a new entry so
// the follow-up rename from / rename to / --- / +++ lines can
// populate the correct paths. flush() drops entries that never
// received a FilePath.
oldPath, newPath, _ := parseUnifiedDiffHeaderPaths(line)
current = &codersdk.ChatGitChange{
ChatID: diff.ChatID,
FilePath: newPath,
ChangeType: "modified",
}
if oldPath != "" && newPath != "" && oldPath != newPath {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
current.ChangeType = "renamed"
}
case current == nil:
continue
case strings.HasPrefix(line, "@@"):
// Entering a hunk. Everything from here until the next
// "diff --git " header is diff content, including any
// added/removed lines that happen to start with "--- "
// or "+++ ". Those must no longer be treated as file
// headers.
inHunk = true
case !inHunk && strings.HasPrefix(line, "new file mode "):
current.ChangeType = "added"
case !inHunk && strings.HasPrefix(line, "deleted file mode "):
current.ChangeType = "deleted"
case !inHunk && strings.HasPrefix(line, "rename from "):
// rename from/rename to paths are repository-relative and
// never carry the a/ or b/ prefix, so we must not strip
// those segments: a real file at a/foo.txt would otherwise
// be truncated to foo.txt.
oldPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename from "))
if oldPath != "" {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
}
current.ChangeType = "renamed"
case !inHunk && strings.HasPrefix(line, "rename to "):
newPath := decodeQuotedDiffLinePath(strings.TrimPrefix(line, "rename to "))
if newPath != "" {
current.FilePath = newPath
}
current.ChangeType = "renamed"
case !inHunk && strings.HasPrefix(line, "--- /dev/null"):
current.ChangeType = "added"
case !inHunk && strings.HasPrefix(line, "+++ /dev/null"):
current.ChangeType = "deleted"
case !inHunk && strings.HasPrefix(line, "--- "):
if current.ChangeType == "added" {
continue
}
if oldPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "--- ")); oldPath != "" && oldPath != "/dev/null" {
oldPathCopy := oldPath
current.OldPath = &oldPathCopy
}
case !inHunk && strings.HasPrefix(line, "+++ "):
if current.ChangeType == "deleted" {
continue
}
if newPath := trimUnifiedDiffPath(strings.TrimPrefix(line, "+++ ")); newPath != "" && newPath != "/dev/null" {
current.FilePath = newPath
}
case inHunk && strings.HasPrefix(line, "+"):
currentAdditions++
case inHunk && strings.HasPrefix(line, "-"):
currentDeletions++
}
}
flush()
return changes
}
// parseUnifiedDiffHeaderPaths extracts the old and new paths from a
// `diff --git ...` header line. Git emits paths in one of two forms:
//
// 1. Quoted: `diff --git "a/<old>" "b/<new>"`. Used when paths contain
// control characters, backslashes, double quotes, or (with the default
// core.quotepath setting) bytes above 0x7f. The contents are C-quoted.
// 2. Unquoted: `diff --git a/<old> b/<new>`. Used for simple paths, which
// may still contain spaces. Because there is no delimiter between the
// two paths, this form is ambiguous when paths contain spaces: we rely
// on the git convention that non-rename diffs repeat the same path in
// both halves.
//
// For the unquoted form we first search for a split point at ` b/` where
// the left and right halves are equal after stripping the `a/` and `b/`
// prefixes (the non-rename case). If that fails but the line contains only
// a single space, we split there for simple renames with no embedded
// whitespace. Otherwise we return ok=false and let the caller rely on the
// subsequent `rename from`, `rename to`, `--- `, and `+++ ` lines.
func parseUnifiedDiffHeaderPaths(line string) (oldPath string, newPath string, ok bool) {
raw := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
if raw == "" {
return "", "", false
}
if strings.HasPrefix(raw, `"`) {
old, rest, ok := consumeQuotedDiffPath(raw)
if !ok {
return "", "", false
}
rest = strings.TrimLeft(rest, " ")
newp, _, ok := consumeQuotedDiffPath(rest)
if !ok {
return "", "", false
}
// The unquoted values already have their surrounding quotes removed,
// so we must not feed them to trimUnifiedDiffPath (which would strip
// any legitimate leading or trailing quote characters in the file
// name). Only strip the a/ or b/ prefix here.
return stripUnifiedDiffPrefix(old), stripUnifiedDiffPrefix(newp), true
}
if !strings.HasPrefix(raw, "a/") {
return "", "", false
}
for offset := 0; offset < len(raw); {
idx := strings.Index(raw[offset:], " b/")
if idx < 0 {
break
}
pos := offset + idx
left := trimUnifiedDiffPath(raw[:pos])
right := trimUnifiedDiffPath(raw[pos+1:])
if left == right {
return left, right, true
}
offset = pos + 1
}
// No equal split was found. If the line only contains a single space,
// the split is unambiguous and this is a simple rename whose paths
// happen to differ. Splitting the quoted-path form was handled above,
// so we know the raw form has no quoting to worry about here.
if strings.Count(raw, " ") == 1 {
idx := strings.Index(raw, " b/")
if idx > 0 {
return trimUnifiedDiffPath(raw[:idx]), trimUnifiedDiffPath(raw[idx+1:]), true
}
}
return "", "", false
}
// consumeQuotedDiffPath reads one C-quoted path from the start of s and
// returns the unquoted value along with the remainder of the string. The
// leading character of s must be `"`. git's C-quoting matches Go's quoted
// string syntax closely enough for strconv.Unquote to handle the common
// cases (octal byte escapes like `\303`, and the usual `\t`, `\n`, `\"`,
// `\\`).
func consumeQuotedDiffPath(s string) (path string, rest string, ok bool) {
if !strings.HasPrefix(s, `"`) {
return "", "", false
}
for i := 1; i < len(s); i++ {
switch s[i] {
case '\\':
// Skip the next byte so an escaped quote does not terminate
// the literal early. Bounds-check to avoid running off the
// end of a malformed input.
if i+1 >= len(s) {
return "", "", false
}
i++
case '"':
unq, err := strconv.Unquote(s[:i+1])
if err != nil {
return "", "", false
}
return unq, s[i+1:], true
}
}
return "", "", false
}
// trimUnifiedDiffPath decodes a path taken from a `--- ` or `+++ ` line
// of a unified diff. Those lines always prefix the path with `a/` or `b/`,
// so the prefix is stripped after any C-quote decoding.
func trimUnifiedDiffPath(path string) string {
return stripUnifiedDiffPrefix(decodeQuotedDiffLinePath(path))
}
// decodeQuotedDiffLinePath decodes a git-emitted path without stripping
// any `a/` or `b/` prefix. Git only adds those prefixes to `diff --git`,
// `--- `, and `+++ ` lines, so `rename from`, `rename to`, and similar
// lines must use this helper to avoid truncating a real leading `a/` or
// `b/` directory component.
func decodeQuotedDiffLinePath(path string) string {
path = strings.TrimSpace(path)
// Git quotes the whole path with double quotes and C-style escapes when
// it contains control characters, backslashes, double quotes, or (with
// the default core.quotepath setting) bytes above 0x7f. strconv.Unquote
// understands the same escape vocabulary for the common cases.
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
if unq, err := strconv.Unquote(path); err == nil {
return unq
}
return strings.Trim(path, `"`)
}
return path
}
func stripUnifiedDiffPrefix(path string) string {
switch {
case strings.HasPrefix(path, "a/"), strings.HasPrefix(path, "b/"):
return path[2:]
default:
return path
}
}
// agentgitOversizePlaceholderPrefix matches the literal prefix that
// agent/agentgit substitutes for a repository's UnifiedDiff when the
// raw diff exceeds maxTotalDiffSize (3 MiB). See
// agent/agentgit/agentgit.go. Multi-repo aggregates assembled by
// buildLocalChatDiffContents can mix real `diff --git` chunks with
// this placeholder, in which case parseChatGitChangesFromUnifiedDiff
// returns a non-zero count for the real chunks while silently
// dropping the placeholder repo. Detecting the prefix separately
// lets renderChatDiffSummary flag the omission so the user is not
// misled into thinking the summary is exhaustive. Kept as a local
// prefix match because the coupling is narrow and the string is
// stable.
const agentgitOversizePlaceholderPrefix = "Total diff too large to show. Size:"
// hasOversizedRepoPlaceholder reports whether the combined unified
// diff contains at least one agentgit oversize-repo placeholder.
// Matching is scoped to lines that start with the placeholder prefix
// so a false positive from a diff body that legitimately contains the
// phrase (e.g. as a `+` added line inside a real patch) cannot
// trigger the omission notice. agentgit always writes the
// placeholder as the entire UnifiedDiff for a repo, and
// buildLocalChatDiffContents joins segments with "\n", so a real
// placeholder repo always appears on its own line after the join.
func hasOversizedRepoPlaceholder(diff string) bool {
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, agentgitOversizePlaceholderPrefix) {
return true
}
}
return false
}
func renderChatDiffSummary(diff codersdk.ChatDiffContents) string {
changes := parseChatGitChangesFromUnifiedDiff(diff)
if len(changes) == 0 {
// The diff text might be non-empty but not in `diff --git`
// format (for example `agent/agentgit` emits a "Total diff
// too large to show..." placeholder when the raw diff exceeds
// the read limit). Report that changes exist but could not
// be summarized so we do not mislead the user into thinking
// the workspace is clean.
if strings.TrimSpace(diff.Diff) != "" {
return "Changes present but could not be summarized."
}
return "No changes detected."
}
label := "files"
if len(changes) == 1 {
label = "file"
}
lines := []string{fmt.Sprintf("%d %s changed:", len(changes), label)}
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)
}
line := fmt.Sprintf(" %-8s %s", change.ChangeType, path)
if change.DiffSummary != nil && strings.TrimSpace(*change.DiffSummary) != "" {
line = fmt.Sprintf("%s (%s)", line, sanitizeTerminalRenderableText(*change.DiffSummary))
}
lines = append(lines, line)
}
// A multi-repo aggregate can mix real diff chunks (counted
// above) with agentgit's oversize placeholder for repos whose
// raw diff exceeds maxTotalDiffSize. The placeholder does not
// contribute to the files-changed count because it is not in
// `diff --git` format, so without this notice the summary would
// silently underreport the changeset.
if hasOversizedRepoPlaceholder(diff.Diff) {
lines = append(lines, " (some repositories omitted: diff too large to summarize)")
}
return strings.Join(lines, "\n")
}
func renderStyledDiffBody(styles tuiStyles, diff string) string {
diff = sanitizeTerminalRenderableText(diff)
if strings.TrimSpace(diff) == "" {
return styles.dimmedText.Render("No diff contents.")
}
lines := strings.Split(diff, "\n")
inHunk := false
for i, line := range lines {
// Track whether we're inside a hunk body so styling can
// distinguish legitimate header `--- `/`+++ ` lines from
// additions/deletions whose content happens to start with
// those prefixes (for example a `+++ ` content line whose
// text begins with `++ `). Matches the parser's inHunk
// bookkeeping in parseChatGitChangesFromUnifiedDiff.
switch {
case strings.HasPrefix(line, "diff --git "):
inHunk = false
case strings.HasPrefix(line, "@@"):
inHunk = true
}
lines[i] = styleUnifiedDiffLine(styles, line, inHunk)
}
return strings.Join(lines, "\n")
}
func styleUnifiedDiffLine(styles tuiStyles, line string, inHunk bool) string {
switch {
case strings.HasPrefix(line, "diff --git "):
return styles.selectedItem.Render(line)
case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "new file mode "),
strings.HasPrefix(line, "deleted file mode "),
strings.HasPrefix(line, "rename from "),
strings.HasPrefix(line, "rename to "),
strings.HasPrefix(line, "Binary files "):
return styles.subtitle.Render(line)
case !inHunk && (strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ")):
return styles.subtitle.Render(line)
case strings.HasPrefix(line, "@@"):
return styles.warningText.Render(line)
case strings.HasPrefix(line, "+"):
return styles.toolSuccess.Render(line)
case strings.HasPrefix(line, "-"):
return styles.errorText.Render(line)
default:
return line
}
}
// renderDiffDrawer builds the diff overlay contents. The caller is
// responsible for producing summary with renderChatDiffSummary and
// styledBody with renderStyledDiffBody so that every View() redraw
// does not walk the full (potentially 4 MiB) diff through
// parseChatGitChangesFromUnifiedDiff or re-style every line through
// lipgloss. chatViewModel caches both in diffSummary and
// diffStyledBody for this reason. If styledBody is empty the caller
// had no cache (for example tests that construct diffs directly), so
// fall back to computing it here instead of silently rendering an
// empty body.
func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, summary, styledBody string, 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, " • ")))
}
diffBody := styledBody
if diffBody == "" {
diffBody = renderStyledDiffBody(styles, diff.Diff)
}
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
}