Files
coder/cli/exp_agents_render_test.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

865 lines
35 KiB
Go

package cli //nolint:testpackage // Tests unexported chat TUI render helpers.
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"testing"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func TestExpAgentsRender(t *testing.T) {
t.Parallel()
styles := newTUIStyles()
t.Run("MessagesToBlocks", func(t *testing.T) {
t.Parallel()
user, assistant, tool := codersdk.ChatMessageRoleUser, codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool
msg := func(role codersdk.ChatMessageRole, parts ...codersdk.ChatMessagePart) codersdk.ChatMessage {
return codersdk.ChatMessage{Role: role, Content: parts}
}
text := func(body string) codersdk.ChatMessagePart {
return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: body}
}
reasoning := func(body string) codersdk.ChatMessagePart {
return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeReasoning, Text: body}
}
call := func(name, id, args string) codersdk.ChatMessagePart {
return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, ToolName: name, ToolCallID: id, Args: rawJSON(args)}
}
result := func(name, id, body string, isError bool) codersdk.ChatMessagePart {
return codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolResult, ToolName: name, ToolCallID: id, Result: rawJSON(body), IsError: isError}
}
tests := []struct {
name string
in []codersdk.ChatMessage
want []chatBlock
}{
{name: "EmptyMessages", want: []chatBlock{}},
{name: "UserText", in: []codersdk.ChatMessage{msg(user, text("hello"))}, want: []chatBlock{{kind: blockText, role: user, text: "hello"}}},
{name: "AssistantText", in: []codersdk.ChatMessage{msg(assistant, text("hi there"))}, want: []chatBlock{{kind: blockText, role: assistant, text: "hi there"}}},
{name: "ToolCallPart", in: []codersdk.ChatMessage{msg(assistant, call("weather", "call-1", `{"city":"SF"}`))}, want: []chatBlock{{kind: blockToolCall, role: assistant, toolName: "weather", toolID: "call-1", args: `{"city":"SF"}`}}},
{name: "ToolResultPart", in: []codersdk.ChatMessage{msg(tool, result("weather", "call-1", `{"temp":"68F"}`, true))}, want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "weather", toolID: "call-1", result: `{"temp":"68F"}`, isError: true}}},
{
name: "MultipleMessagesInOrder",
in: []codersdk.ChatMessage{
msg(user, text("question")),
msg(assistant, reasoning("thinking"), call("search", "call-3", `{"q":"docs"}`), text("answer")),
},
want: []chatBlock{
{kind: blockText, role: user, text: "question"},
{kind: blockReasoning, role: assistant, text: "thinking"},
{kind: blockToolCall, role: assistant, toolName: "search", toolID: "call-3", args: `{"q":"docs"}`},
{kind: blockText, role: assistant, text: "answer"},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, messagesToBlocks(tt.in))
})
}
t.Run("KeepsToolCallsAndLaterResultsSeparateByToolID", func(t *testing.T) {
t.Parallel()
blocks := messagesToBlocks([]codersdk.ChatMessage{
msg(assistant,
call("github__get_pull_request", "call-1", `{"owner":"openclaw","repo":"openclaw","pull_number":58036}`),
call("github__get_pull_request", "call-2", `{"owner":"openclaw","repo":"openclaw","pull_number":58037}`),
),
msg(tool,
result("github__get_pull_request", "call-1", `{"base":{"ref":"main"}}`, false),
result("github__get_pull_request", "call-2", `{"base":{"ref":"main"}}`, false),
),
})
require.Len(t, blocks, 4)
require.Equal(t,
[]chatBlockKind{blockToolCall, blockToolCall, blockToolResult, blockToolResult},
[]chatBlockKind{blocks[0].kind, blocks[1].kind, blocks[2].kind, blocks[3].kind},
)
require.Equal(t, []string{"call-1", "call-2", "call-1", "call-2"}, []string{blocks[0].toolID, blocks[1].toolID, blocks[2].toolID, blocks[3].toolID})
})
})
t.Run("MergeConsecutiveToolBlocks", func(t *testing.T) {
t.Parallel()
assistant, tool := codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageRoleTool
call := func(name, id, args string) chatBlock {
return chatBlock{kind: blockToolCall, role: assistant, toolName: name, toolID: id, args: args}
}
result := func(name, id, body string) chatBlock {
return chatBlock{kind: blockToolResult, role: tool, toolName: name, toolID: id, result: body}
}
for _, tt := range []struct {
name string
in []chatBlock
want []chatBlock
}{
{
name: "MergesAdjacentEmptyToolIDCallAndResult",
in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("read_file", "", `{"content":"hello"}`)},
want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}},
},
{
name: "ExistingToolIDMergeStillWorks",
in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), result("read_file", "call-1", `{"content":"hello"}`)},
want: []chatBlock{{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"main.go"}`, result: `{"content":"hello"}`}},
},
{
name: "MultiplePairs",
in: []chatBlock{
call("read_file", "call-1", `{"path":"one.txt"}`),
result("read_file", "call-1", `{"ok":true}`),
call("list_dir", "call-2", `{"path":"/tmp"}`),
result("list_dir", "call-2", `{"entries":[]}`),
},
want: []chatBlock{
{kind: blockToolResult, role: tool, toolName: "read_file", toolID: "call-1", args: `{"path":"one.txt"}`, result: `{"ok":true}`},
{kind: blockToolResult, role: tool, toolName: "list_dir", toolID: "call-2", args: `{"path":"/tmp"}`, result: `{"entries":[]}`},
},
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := mergeConsecutiveToolBlocks(tt.in)
require.Equal(t, tt.want, got)
})
}
t.Run("NegativeMergeCases", func(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
in []chatBlock
want []chatBlock
}{
{
name: "DifferentToolNames",
in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)},
want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), result("list_dir", "", `{"entries":[]}`)},
},
{
name: "NonAdjacentEmptyToolID",
in: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)},
want: []chatBlock{call("read_file", "", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "", `{"content":"hello"}`)},
},
{
name: "NonAdjacentMatchingToolID",
in: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)},
want: []chatBlock{call("read_file", "call-1", `{"path":"main.go"}`), {kind: blockText, role: assistant, text: "still thinking"}, result("read_file", "call-1", `{"content":"hello"}`)},
},
{
name: "OrphanedCall",
in: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)},
want: []chatBlock{call("read_file", "call-orphan", `{"path":"solo.txt"}`)},
},
{
name: "OrphanedResult",
in: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)},
want: []chatBlock{result("read_file", "call-orphan", `{"content":"hello"}`)},
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := mergeConsecutiveToolBlocks(tt.in)
require.Equal(t, tt.want, got)
})
}
})
})
t.Run("ToolArgsSummary", func(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
toolName string
args string
assert func(t *testing.T, summary string)
}{
{name: "CreateWorkspaceUsesNameField", toolName: "coder_create_workspace", args: `{"name":"my-workspace"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-workspace)", summary) }},
{name: "CreateWorkspaceUsesWorkspaceNameField", toolName: "coder_create_workspace", args: `{"workspace_name":"my-ws","template":"docker"}`, assert: func(t *testing.T, summary string) { require.Equal(t, "(my-ws)", summary) }},
{name: "WithUnicodeTruncatesOnRuneBoundary", toolName: "weather", args: strings.Repeat("こんにちは世界", 10), assert: func(t *testing.T, summary string) {
require.NotEmpty(t, summary)
require.True(t, utf8.ValidString(summary))
require.True(t, strings.HasSuffix(summary, "…"))
require.LessOrEqual(t, len([]rune(summary)), toolSummaryFallbackWidth)
require.Contains(t, summary, "こんにちは")
}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tt.assert(t, toolArgsSummary(tt.toolName, tt.args))
})
}
require.Equal(t, "(created-ws)", toolResultSummary("coder_create_workspace", "", `{"workspace_name":"created-ws"}`))
})
t.Run("RenderToolCall", func(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
part codersdk.ChatMessagePart
width int
assert func(t *testing.T, output string)
}{
{name: "ShowsHumanizedToolNameAndContext", part: codersdk.ChatMessagePart{ToolName: "github__get_pull_request", Args: rawJSON(`{"owner":"openclaw","repo":"openclaw","pull_number":58036}`)}, width: 60, assert: func(t *testing.T, output string) {
require.Contains(t, output, " ○ get pull request")
require.Contains(t, output, "(openclaw/openclaw)")
}},
{name: "ShowsTruncatedCommandPreview", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la /tmp/with/a/very/long/path"}`)}, width: 30, assert: func(t *testing.T, output string) {
require.Contains(t, output, "○ execute command")
require.Contains(t, output, `"ls -la`)
require.Contains(t, output, "…")
}},
{name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, output string) {
require.Contains(t, output, "🗜️ Context compacted")
require.NotContains(t, output, pendingToolIcon)
}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var output string
require.NotPanics(t, func() {
output = plainText(renderToolCallBlock(styles, chatBlock{
kind: blockToolCall,
toolName: tt.part.ToolName,
args: compactTranscriptJSON(tt.part.Args),
}, tt.width))
})
tt.assert(t, output)
})
}
})
t.Run("RenderToolResult", func(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
part codersdk.ChatMessagePart
width int
assert func(t *testing.T, rawOutput, plainOutput string)
}{
{name: "SuccessShowsCheckPrefixAndArgsContext", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Args: rawJSON(`{"command":"ls -la"}`), Result: rawJSON(`{"ok":true}`)}, width: 40, assert: func(t *testing.T, _, output string) {
require.Contains(t, output, "✓ execute command")
require.Contains(t, output, `"ls -la"`)
}},
{name: "ErrorShowsErrorStyleAndMessage", part: codersdk.ChatMessagePart{ToolName: "coder_execute_command", Result: rawJSON(`{"error":"command not found"}`), IsError: true}, width: 40, assert: func(t *testing.T, rawOutput, plainOutput string) {
require.Contains(t, rawOutput, styles.errorText.Render("✗ execute command"))
require.Contains(t, plainOutput, `"command not found"`)
}},
{name: "MergedCreateWorkspaceResultKeepsArgsSummary", part: codersdk.ChatMessagePart{ToolName: "coder_create_workspace", ToolCallID: "call-create-workspace", Args: rawJSON(`{"name":"merged-workspace"}`), Result: rawJSON(`{"workspace_name":"merged-workspace","status":"created"}`)}, width: 60, assert: func(t *testing.T, _, output string) {
require.Contains(t, output, "✓ create workspace")
require.Contains(t, output, "(merged-workspace)")
}},
{name: "ContextCompactionRendersBanner", part: codersdk.ChatMessagePart{ToolName: contextCompactionToolName}, width: 40, assert: func(t *testing.T, _, output string) {
require.Contains(t, output, "🗜️ Context compacted")
require.NotContains(t, output, "✓")
}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var rawOutput string
require.NotPanics(t, func() {
rawOutput = renderToolResultBlock(styles, chatBlock{
kind: blockToolResult,
toolName: tt.part.ToolName,
args: compactTranscriptJSON(tt.part.Args),
result: compactTranscriptJSON(tt.part.Result),
isError: tt.part.IsError,
}, tt.width)
})
tt.assert(t, rawOutput, plainText(rawOutput))
})
}
})
t.Run("RenderCompaction", func(t *testing.T) {
t.Parallel()
output := plainText(renderCompaction(styles, 20))
require.Contains(t, output, "🗜️ Context compacted")
})
t.Run("RenderStatusBar", func(t *testing.T) {
t.Parallel()
u := func(total, limit int64) *codersdk.ChatMessageUsage {
return &codersdk.ChatMessageUsage{TotalTokens: int64Ptr(total), ContextLimit: int64Ptr(limit)}
}
for _, tt := range []struct {
name string
status codersdk.ChatStatus
usage *codersdk.ChatMessageUsage
queue int
interrupting, reconnecting bool
width, maxWidth int
wantRaw string
wantPlain, avoidPlain []string
}{
{name: "RunningOmitsUsageWhenNil", status: codersdk.ChatStatusRunning, width: 80, avoidPlain: []string{"tokens:"}},
{name: "RunningShowsTokenUsage", status: codersdk.ChatStatusRunning, usage: u(50, 100), width: 80, wantPlain: []string{"tokens: 50/100"}},
{name: "RunningWarnsAndShowsTransientStates", status: codersdk.ChatStatusRunning, usage: u(81, 100), interrupting: true, reconnecting: true, width: 80, wantRaw: styles.warningText.Render("tokens: 81/100"), wantPlain: []string{"interrupting…", "reconnecting…"}},
{name: "RunningShowsCriticalUsage", status: codersdk.ChatStatusRunning, usage: u(96, 100), width: 80, wantRaw: styles.criticalText.Render("tokens: 96/100")},
{name: "PendingShowsQueue", status: codersdk.ChatStatusPending, queue: 2, width: 80, wantPlain: []string{"queued: 2"}},
{name: "NarrowWidthFits", status: codersdk.ChatStatusRunning, usage: u(96, 100), queue: 2, interrupting: true, reconnecting: true, width: 20, maxWidth: 20},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var output string
require.NotPanics(t, func() {
output = renderStatusBar(styles, nil, tt.status, tt.usage, tt.queue, tt.interrupting, tt.reconnecting, tt.width)
})
plain := plainText(output)
require.Contains(t, output, styles.statusColor(tt.status).Render(string(tt.status)))
if tt.wantRaw != "" {
require.Contains(t, output, tt.wantRaw)
}
for _, want := range tt.wantPlain {
require.Contains(t, plain, want)
}
for _, avoid := range tt.avoidPlain {
require.NotContains(t, plain, avoid)
}
if tt.maxWidth > 0 {
require.NotEmpty(t, plain)
require.LessOrEqual(t, lipgloss.Width(plain), tt.maxWidth)
require.LessOrEqual(t, lipgloss.Width(output), tt.width)
}
})
}
})
t.Run("RenderBlock", func(t *testing.T) {
t.Parallel()
renderOutput := func(block chatBlock, expanded, plain bool, width int) string {
output := renderBlock(styles, block, expanded, width)
if plain {
return plainText(output)
}
return output
}
assertOutput := func(t *testing.T, output string, want, avoid []string, lines int, lastLine string) {
t.Helper()
for _, s := range want {
require.Contains(t, output, s)
}
for _, s := range avoid {
require.NotContains(t, output, s)
}
if lines > 0 {
split := strings.Split(output, "\n")
require.Len(t, split, lines)
if lastLine != "" {
require.Equal(t, lastLine, strings.TrimRight(split[len(split)-1], " "))
}
}
}
for _, tt := range []struct {
name string
block chatBlock
want []string
avoid []string
}{
{name: "UserIncludesYouPrefix", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"}, want: []string{"You: hello"}},
{name: "AssistantRendersMarkdown", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "- first\n- second"}, want: []string{"• first", "• second"}, avoid: []string{"- first"}},
{name: "ToolRendersDimmed", block: chatBlock{kind: blockText, role: codersdk.ChatMessageRoleTool, text: "tool output"}, want: []string{styles.dimmedText.Render("tool output")}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assertOutput(t, renderOutput(tt.block, false, tt.block.role != codersdk.ChatMessageRoleTool, 40), tt.want, tt.avoid, 0, "")
})
}
for _, tt := range []struct {
name string
block chatBlock
width int
collapsedWant []string
collapsedAvoid []string
collapsedLines int
collapsedLastLine string
expandedWant []string
expandedAvoid []string
expandedLines int
expandedLastLine string
}{
{
name: "Reasoning",
block: chatBlock{kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "line1\nline2\nline3\nline4"},
width: 40,
collapsedWant: []string{"thinking: line1"},
collapsedLines: 3,
collapsedLastLine: "line3…",
expandedWant: []string{"line4"},
expandedAvoid: []string{"line4…"},
expandedLines: 4,
},
{
name: "ToolCall",
block: chatBlock{kind: blockToolCall, toolName: "read_file", args: `{"path":"very/long/path.txt","recursive":true}`},
width: 60,
collapsedWant: []string{"○ read file", "(very/long/path.txt)"},
collapsedAvoid: []string{"\n", "args:"},
expandedWant: []string{"○ read file", "args:", `{"path":"very/long/path.txt","recursive":true}`, "\n"},
},
{
name: "ToolResult",
block: chatBlock{kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`},
width: 60,
collapsedWant: []string{"✓ read file", "(a.txt)"},
collapsedAvoid: []string{"\n", "result:"},
expandedWant: []string{"✓ read file", "args:", "result:", `{"path":"a.txt","contents":"hello"}`, "\n"},
},
{
name: "CollapsedToolCallShowsRunCount",
block: chatBlock{kind: blockToolCall, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, collapsedCount: 3},
width: 80,
collapsedWant: []string{"○ get pull request..."},
},
{
name: "CollapsedToolResultShowsRunCount",
block: chatBlock{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw"}`, result: `{"ok":true}`, collapsedCount: 10},
width: 80,
collapsedWant: []string{"✓ get pull request (x10)"},
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
collapsed := renderOutput(tt.block, false, true, tt.width)
assertOutput(t, collapsed, tt.collapsedWant, tt.collapsedAvoid, tt.collapsedLines, tt.collapsedLastLine)
if len(tt.expandedWant)+len(tt.expandedAvoid)+tt.expandedLines > 0 || tt.expandedLastLine != "" {
expanded := renderOutput(tt.block, true, true, tt.width)
assertOutput(t, expanded, tt.expandedWant, tt.expandedAvoid, tt.expandedLines, tt.expandedLastLine)
}
})
}
t.Run("CompactionRendersBanner", func(t *testing.T) {
t.Parallel()
output := plainText(renderBlock(styles, chatBlock{kind: blockCompaction}, false, 40))
require.Contains(t, output, "🗜️ Context compacted")
})
})
t.Run("RenderChatBlocks", func(t *testing.T) {
t.Parallel()
t.Run("MixedMessagesRenderInOrder", func(t *testing.T) {
t.Parallel()
blocks := []chatBlock{
{kind: blockText, role: codersdk.ChatMessageRoleUser, text: "hello"},
{kind: blockReasoning, role: codersdk.ChatMessageRoleAssistant, text: "thinking"},
{kind: blockToolResult, toolName: "read_file", args: `{"path":"a.txt"}`, result: `{"path":"a.txt","contents":"hello"}`},
{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "done"},
}
output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 60))
require.Contains(t, output, "You: hello")
require.Contains(t, output, "thinking: thinking")
require.Contains(t, output, "✓ read file")
require.Contains(t, output, "done")
require.Less(t, strings.Index(output, "You: hello"), strings.Index(output, "thinking: thinking"))
require.Less(t, strings.Index(output, "thinking: thinking"), strings.Index(output, "✓ read file"))
require.Less(t, strings.Index(output, "✓ read file"), strings.LastIndex(output, "done"))
})
t.Run("SelectedBlockUsesLeftBorderIndicator", func(t *testing.T) {
t.Parallel()
blocks := []chatBlock{{kind: blockText, role: codersdk.ChatMessageRoleAssistant, text: "assistant reply"}}
output := plainText(renderChatBlocks(styles, blocks, 0, map[int]bool{}, false, 60))
require.Contains(t, output, "│ assistant reply")
})
t.Run("CollapsesConsecutiveSameNameToolResults", func(t *testing.T) {
t.Parallel()
blocks := []chatBlock{
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`},
}
output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80))
require.Equal(t, 2, strings.Count(output, "✓"))
require.Contains(t, output, "get pull request (x3)")
require.Contains(t, output, "create file")
})
t.Run("DoesNotCollapseDifferentToolResults", func(t *testing.T) {
t.Parallel()
blocks := []chatBlock{
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":2}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":3}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "create_file", args: `{"path":"main.go"}`, result: `{"ok":true}`},
}
output := plainText(renderChatBlocks(styles, blocks, -1, map[int]bool{}, true, 80))
require.Equal(t, 4, strings.Count(output, "✓"))
require.NotContains(t, output, "get pull request (x3)")
require.Contains(t, output, "create file")
})
t.Run("ExpandedToolBlockPreventsCollapse", func(t *testing.T) {
t.Parallel()
blocks := []chatBlock{
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
{kind: blockToolResult, toolName: "github__get_pull_request", args: `{"owner":"openclaw","repo":"openclaw","pull_number":1}`, result: `{"base":{"ref":"main"}}`},
}
output := plainText(renderChatBlocks(styles, blocks, 1, map[int]bool{1: true}, false, 80))
require.Equal(t, 2, strings.Count(output, "✓"))
require.NotContains(t, output, "(x2)")
require.Contains(t, output, "result:")
})
})
t.Run("RenderDiffDrawer", func(t *testing.T) {
t.Parallel()
branch := "feature/chat-ui"
prURL := "https://example.com/pulls/123"
for _, tt := range []struct {
name string
diff codersdk.ChatDiffContents
changes []codersdk.ChatGitChange
assert func(t *testing.T, output string)
}{
{name: "ShowsMetadataWhenPresent", diff: codersdk.ChatDiffContents{Branch: &branch, PullRequestURL: &prURL}, assert: func(t *testing.T, output string) {
require.Contains(t, output, "Branch: feature/chat-ui")
require.Contains(t, output, "PR: https://example.com/pulls/123")
}},
{name: "ShowsDiffContent", diff: codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+added line"}, changes: []codersdk.ChatGitChange{{FilePath: "a.txt", ChangeType: "modified"}}, assert: func(t *testing.T, output string) {
require.Contains(t, output, "diff --git a/a.txt b/a.txt")
require.Contains(t, output, "+added line")
}},
{name: "ShowsPlaceholderForEmptyDiff", assert: func(t *testing.T, output string) { require.Contains(t, output, "No diff contents.") }},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var output string
require.NotPanics(t, func() { output = plainText(renderDiffDrawer(styles, tt.diff, tt.changes, 90, 20)) })
tt.assert(t, output)
})
}
})
t.Run("RenderDiffDrawerSanitizesUntrustedContent", func(t *testing.T) {
t.Parallel()
rawOutput := renderDiffDrawer(
styles,
codersdk.ChatDiffContents{Diff: "diff --git a/a.txt b/a.txt\n+safe\x1b]52;c;clipboard\x07line"},
[]codersdk.ChatGitChange{{
FilePath: "a.txt\x1b]52;c;clipboard\x07",
ChangeType: "modified",
}},
90,
20,
)
output := plainText(rawOutput)
require.Contains(t, output, "diff --git a/a.txt b/a.txt")
require.Contains(t, output, "+safeline")
require.Contains(t, output, "modified a.txt")
require.NotContains(t, rawOutput, "clipboard")
require.NotContains(t, rawOutput, "\x1b]52")
})
t.Run("RenderModelPicker", func(t *testing.T) {
t.Parallel()
catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
Provider: "OpenAI",
Available: true,
Models: []codersdk.ChatModel{{ID: "gpt-4o", Provider: "OpenAI", Model: "gpt-4o", DisplayName: "GPT-4o"}, {ID: "gpt-4.1", Provider: "OpenAI", Model: "gpt-4.1", DisplayName: "GPT-4.1"}},
}, {
Provider: "Anthropic",
Available: false,
UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey,
}, {
Provider: "Local",
Available: true,
Models: nil,
}}}
for _, tt := range []struct {
name string
selectedModel string
selectedIndex int
assert func(t *testing.T, output string)
}{
{name: "GroupsModelsByProvider", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) {
require.Contains(t, output, "OpenAI")
require.Contains(t, output, "GPT-4o")
require.Contains(t, output, "GPT-4.1")
}},
{name: "ShowsCursorIndicatorOnSelectedPosition", selectedModel: "gpt-4.1", selectedIndex: 1, assert: func(t *testing.T, output string) {
require.Contains(t, output, "> GPT-4.1")
require.Contains(t, output, " GPT-4o")
}},
{name: "HidesProvidersWithoutModels", selectedModel: "gpt-4o", assert: func(t *testing.T, output string) {
require.Contains(t, output, "OpenAI")
require.NotContains(t, output, "Anthropic")
require.NotContains(t, output, "Local")
}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var output string
require.NotPanics(t, func() {
output = plainText(renderModelPicker(styles, catalog, tt.selectedModel, tt.selectedIndex, 90, 20))
})
tt.assert(t, output)
})
}
t.Run("ShowsGlobalEmptyStateWhenNoModelsSelectable", func(t *testing.T) {
t.Parallel()
emptyCatalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
Provider: "Anthropic",
Available: false,
UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey,
}, {
Provider: "Local",
Available: true,
Models: nil,
}}}
output := plainText(renderModelPicker(styles, emptyCatalog, "", 0, 90, 20))
require.NotContains(t, output, "Anthropic")
require.NotContains(t, output, "Local")
require.Equal(t, 1, strings.Count(output, "No models available."))
})
})
t.Run("KeepsCursorVisibleWithinWindow", func(t *testing.T) {
t.Parallel()
models := make([]codersdk.ChatModel, 0, 6)
for i := 1; i <= 6; i++ {
models = append(models, codersdk.ChatModel{
ID: fmt.Sprintf("provider:model-%d", i),
Provider: "provider",
Model: fmt.Sprintf("model-%d", i),
DisplayName: fmt.Sprintf("Model %d", i),
})
}
catalog := codersdk.ChatModelsResponse{Providers: []codersdk.ChatModelProvider{{
Provider: "provider",
Available: true,
Models: models,
}}}
output := plainText(renderModelPicker(styles, catalog, "provider:model-5", 4, 60, 8))
require.Contains(t, output, "> Model 5")
require.NotContains(t, output, "Model 1")
})
t.Run("RenderAssistantMarkdown", func(t *testing.T) {
t.Parallel()
output := plainText(renderAssistantMarkdown(styles, "- first\n- second", 60, nil))
require.Contains(t, output, "• first")
require.Contains(t, output, "• second")
require.NotContains(t, output, "- first")
})
t.Run("SanitizeTerminalRenderableText", func(t *testing.T) {
t.Parallel()
output := sanitizeTerminalRenderableText("safe\ttext\n\x1b[31mred\u009b32mgreen\x1b]52;c;clipboard\x07\x1b(Bdone\r\x00")
require.Equal(t, "safe\ttext\nredgreendone", output)
require.NotContains(t, output, "\x1b")
require.NotContains(t, output, "\x07")
require.NotContains(t, output, "\r")
require.NotContains(t, output, "\x00")
})
t.Run("RenderToolDetailStripsTerminalEscapes", func(t *testing.T) {
t.Parallel()
rawOutput := renderToolDetail(styles, "result", "ok\x1b]52;c;clipboard\x07\n\tstill here", 60)
output := plainText(rawOutput)
require.Contains(t, output, "result: ok")
require.Contains(t, output, "still here")
require.NotContains(t, output, "clipboard")
require.NotContains(t, output, "\x1b")
require.NotContains(t, output, "\x07")
})
t.Run("UtilityRenderers", func(t *testing.T) {
t.Parallel()
for _, tt := range []struct{ name, input, want string }{
{name: "WrapPreservingNewlines/PreservesExplicitNewlines", input: "line one\nline two", want: "line one\nline two"},
{name: "WrapPreservingNewlines/EmptyString", input: "", want: ""},
{name: "WrapPreservingNewlines/OnlyNewlines", input: "\n\n\n", want: "\n\n\n"},
} {
require.Equalf(t, tt.want, wrapPreservingNewlines(tt.input, 40), tt.name)
}
for _, tt := range []struct {
name string
input string
max int
assert func(t *testing.T, output string)
}{
{name: "ClampLines/AddsEllipsis", input: "line1\nline2\nline3\nline4", max: 3, assert: func(t *testing.T, output string) {
lines := strings.Split(output, "\n")
require.Len(t, lines, 3)
require.Equal(t, "line3…", lines[2])
}},
{name: "ClampLines/ZeroMax", input: "line1\nline2", max: 0, assert: func(t *testing.T, output string) { require.Empty(t, output) }},
} {
tt.assert(t, clampLines(tt.input, tt.max))
}
for _, tt := range []struct {
name string
prefix string
input string
width int
assert func(t *testing.T, output string)
}{
{name: "RenderPrefixedBlock/IndentsContinuationLines", prefix: "You: ", input: "alpha beta gamma delta", width: 12, assert: func(t *testing.T, output string) {
lines := strings.Split(output, "\n")
require.GreaterOrEqual(t, len(lines), 2)
require.True(t, strings.HasPrefix(lines[1], strings.Repeat(" ", lipgloss.Width("You: "))))
require.Contains(t, output, "You: ")
}},
{name: "RenderPrefixedBlock/EmptyContent", prefix: "You: ", width: 12, assert: func(t *testing.T, output string) { require.Equal(t, "You: ", output) }},
} {
tt.assert(t, renderPrefixedBlock(tt.prefix, tt.input, tt.width))
}
})
t.Run("RenderAskUserQuestion", func(t *testing.T) {
t.Parallel()
firstQuestion := parsedAskQuestion{
Header: "Review plan",
Question: "Which plan should we use?",
Options: []parsedAskOption{
{Label: "Fast path", Value: "fast"},
{Label: "Safe path", Value: "safe"},
},
}
secondQuestion := parsedAskQuestion{
Header: "Risk",
Question: "How much risk is acceptable?",
Options: []parsedAskOption{{Label: "Low", Value: "low"}},
}
renderPlain := func(state *askUserQuestionState, width, height int) string {
return plainText(renderAskUserQuestion(styles, state, width, height))
}
t.Run("BasicRenderShowsQuestionOptionsAndHelp", func(t *testing.T) {
t.Parallel()
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
output := renderPlain(state, 100, 20)
require.Contains(t, output, "Plan Question 1/1")
require.Contains(t, output, firstQuestion.Header)
require.Contains(t, output, firstQuestion.Question)
require.Contains(t, output, "Fast path")
require.Contains(t, output, "Safe path")
require.Contains(t, output, "Other (type custom answer)")
require.Contains(t, output, "↑/↓ navigate")
require.Contains(t, output, "enter select")
})
t.Run("SelectedOptionShowsCursor", func(t *testing.T) {
t.Parallel()
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
state.OptionCursor = 1
output := renderPlain(state, 100, 20)
require.Contains(t, output, "> Safe path")
require.NotContains(t, output, "> Fast path")
})
t.Run("MultipleQuestionsShowProgress", func(t *testing.T) {
t.Parallel()
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion, secondQuestion, firstQuestion})
state.CurrentIndex = 1
output := renderPlain(state, 100, 20)
require.Contains(t, output, "Plan Question 2/3")
require.Contains(t, output, secondQuestion.Header)
require.Contains(t, output, secondQuestion.Question)
})
t.Run("FreeformInputIsVisible", func(t *testing.T) {
t.Parallel()
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
state.OptionCursor = len(firstQuestion.Options)
state.OtherMode = true
state.OtherInput.Focus()
state.OtherInput.SetValue("Need a custom plan")
output := renderPlain(state, 100, 20)
require.Contains(t, output, "Need a custom plan")
require.Contains(t, output, "esc cancel input")
})
t.Run("NarrowTerminalDoesNotPanic", func(t *testing.T) {
t.Parallel()
state := newAskUserQuestionState("tool-1", []parsedAskQuestion{firstQuestion})
var output string
require.NotPanics(t, func() {
output = renderPlain(state, 18, 6)
})
require.NotEmpty(t, strings.TrimSpace(output))
})
})
}
func plainText(text string) string {
return ansiRegexp.ReplaceAllString(text, "")
}
func rawJSON(value string) json.RawMessage {
return json.RawMessage([]byte(value))
}
func int64Ptr(value int64) *int64 {
return &value
}