mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +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.
865 lines
35 KiB
Go
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
|
|
}
|