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.
This commit is contained in:
Michael Suchacz
2026-04-17 12:16:06 +02:00
committed by GitHub
parent ad7f1bdf5b
commit de30488b20
14 changed files with 8373 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
package cli
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/muesli/termenv"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func installTUISignalHandler(p *tea.Program) func() {
ch := make(chan struct{})
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
defer func() {
signal.Stop(sig)
close(ch)
}()
for {
select {
case <-ch:
return
case <-sig:
p.Send(terminateTUIMsg{})
}
}
}()
return func() {
ch <- struct{}{}
}
}
func fitHelpText(width int, candidates ...string) string {
if len(candidates) == 0 {
return ""
}
if width <= 0 {
return candidates[0]
}
for _, candidate := range candidates {
if lipgloss.Width(candidate) <= width {
return candidate
}
}
return truncateText(candidates[len(candidates)-1], width, " •|│:", 1)
}
func truncateText(text string, width int, trimRightCutset string, ellipsisWidth int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(text) <= width {
return text
}
if width <= ellipsisWidth {
return "…"
}
for runes := []rune(text); len(runes) > 0; runes = runes[:len(runes)-1] {
truncated := strings.TrimRight(string(runes), trimRightCutset) + "…"
if lipgloss.Width(truncated) <= width {
return truncated
}
}
return "…"
}
func (r *RootCmd) agentsCommand() *serpent.Command {
var (
workspaceFlag string
modelFlag string
)
return &serpent.Command{
Use: "agents [chat-id]",
Short: "Interactive terminal UI for AI agents.",
Aliases: []string{"agent"},
Options: serpent.OptionSet{
{
Name: "workspace",
Flag: "workspace",
Description: "Associate the chat with a workspace by name, owner/name, or UUID.",
Value: serpent.StringOf(&workspaceFlag),
},
{
Name: "model",
Flag: "model",
Description: "Choose a model by ID, provider/model, or display name.",
Value: serpent.StringOf(&modelFlag),
},
},
Handler: func(inv *serpent.Invocation) error {
client, err := r.InitClient(inv)
if err != nil {
return err
}
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("list organizations: %w", err)
}
if len(orgs) == 0 {
return xerrors.New("no organizations found")
}
defaultOrgID := orgs[0].ID
expClient := codersdk.NewExperimentalClient(client)
if len(inv.Args) > 1 {
return xerrors.New("expected zero or one chat ID")
}
var initialChatID *uuid.UUID
if len(inv.Args) == 1 {
chatID, err := uuid.Parse(inv.Args[0])
if err != nil {
return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err)
}
initialChatID = &chatID
}
var workspaceID *uuid.UUID
if workspaceFlag != "" {
workspace, err := namedWorkspace(inv.Context(), client, workspaceFlag)
if err != nil {
return xerrors.Errorf("resolve workspace %q: %w", workspaceFlag, err)
}
workspaceID = &workspace.ID
}
modelID, err := resolveModel(inv.Context(), expClient, modelFlag)
if err != nil {
return err
}
// Set an explicit color profile before Bubble Tea acquires the
// terminal so lipgloss/termenv don't send OSC color queries that
// can leak back into stdin as literal input in some terminals.
renderer := lipgloss.NewRenderer(
inv.Stdout,
termenv.WithProfile(termenv.TrueColor),
)
renderer.SetHasDarkBackground(true)
model := newExpChatsTUIModel(inv.Context(), expClient, initialChatID, workspaceID, modelID, defaultOrgID)
model.setRenderer(renderer)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithoutSignalHandler(),
tea.WithContext(inv.Context()),
tea.WithInput(inv.Stdin),
tea.WithOutput(inv.Stdout),
)
closeSignalHandler := installTUISignalHandler(program)
defer closeSignalHandler()
runModel, err := program.Run()
if err != nil {
return err
}
if _, ok := runModel.(expChatsTUIModel); !ok {
return xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", runModel, runModel))
}
return nil
},
}
}
//nolint:nilnil // A nil string indicates that no model override was provided.
func resolveModel(ctx context.Context, client *codersdk.ExperimentalClient, modelFlag string) (*string, error) {
if modelFlag == "" {
return nil, nil
}
if _, err := uuid.Parse(modelFlag); err == nil {
return &modelFlag, nil
}
catalog, err := client.ListChatModels(ctx)
if err != nil {
return nil, xerrors.Errorf("listing models: %w", err)
}
for _, provider := range catalog.Providers {
for _, model := range provider.Models {
if model.ID == modelFlag || model.Provider+"/"+model.Model == modelFlag || model.DisplayName == modelFlag {
return &model.ID, nil
}
}
}
return nil, xerrors.Errorf("unknown model %q", modelFlag)
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
package cli
import (
"context"
"io"
"slices"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
type (
chatsListedMsg struct {
chats []codersdk.Chat
err error
}
chatOpenedMsg struct {
generation uint64
chatID uuid.UUID
chat codersdk.Chat
err error
}
chatHistoryMsg struct {
generation uint64
chatID uuid.UUID
messages []codersdk.ChatMessage
err error
}
chatCreatedMsg struct {
generation uint64
chatID uuid.UUID
chat codersdk.Chat
err error
}
chatPlanModeUpdatedMsg struct {
generation uint64
chatID uuid.UUID
err error
}
messageSentMsg struct {
generation uint64
chatID uuid.UUID
resp codersdk.CreateChatMessageResponse
err error
}
chatInterruptedMsg struct {
generation uint64
chatID uuid.UUID
chat codersdk.Chat
err error
}
modelsListedMsg struct {
catalog codersdk.ChatModelsResponse
err error
}
gitChangesMsg struct {
generation uint64
chatID uuid.UUID
changes []codersdk.ChatGitChange
err error
}
diffContentsMsg struct {
generation uint64
chatID uuid.UUID
diff codersdk.ChatDiffContents
err error
}
chatStreamEventMsg struct {
generation uint64
chatID uuid.UUID
event codersdk.ChatStreamEvent
err error
}
// showAskUserQuestionMsg tells the parent model to open the
// ask-user-question overlay.
showAskUserQuestionMsg struct {
state *askUserQuestionState
}
// hideAskUserQuestionMsg tells the parent model to close the
// ask-user-question overlay.
hideAskUserQuestionMsg struct{}
// toolResultsSubmittedMsg is sent after the async SubmitToolResults
// call completes.
toolResultsSubmittedMsg struct {
generation uint64
chatID uuid.UUID
err error
}
streamRetryMsg struct {
generation uint64
}
toggleModelPickerMsg struct{}
toggleDiffDrawerMsg struct{}
)
func scheduleStreamRetry(generation uint64, delay time.Duration) tea.Cmd {
return tea.Tick(delay, func(time.Time) tea.Msg {
return streamRetryMsg{generation: generation}
})
}
func apiCmd[T any](fn func() (T, error), wrap func(T, error) tea.Msg) tea.Cmd {
return func() tea.Msg {
value, err := fn()
return wrap(value, err)
}
}
func loadChatHistoryCmd(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID, generation uint64) tea.Cmd {
return apiCmd(func() ([]codersdk.ChatMessage, error) {
var (
allMessages []codersdk.ChatMessage
opts *codersdk.ChatMessagesPaginationOptions
)
for {
resp, err := client.GetChatMessages(ctx, chatID, opts)
if err != nil {
return nil, err
}
allMessages = append(allMessages, resp.Messages...)
if !resp.HasMore || len(resp.Messages) == 0 {
break
}
opts = &codersdk.ChatMessagesPaginationOptions{
BeforeID: resp.Messages[len(resp.Messages)-1].ID,
}
}
slices.SortStableFunc(allMessages, func(a, b codersdk.ChatMessage) int {
switch {
case a.CreatedAt.Before(b.CreatedAt):
return -1
case a.CreatedAt.After(b.CreatedAt):
return 1
case a.ID < b.ID:
return -1
case a.ID > b.ID:
return 1
default:
return 0
}
})
return allMessages, nil
}, func(messages []codersdk.ChatMessage, err error) tea.Msg {
return chatHistoryMsg{generation: generation, chatID: chatID, messages: messages, err: err}
})
}
func submitAskUserQuestionCmd(client *codersdk.Client, chatID uuid.UUID, generation uint64, state *askUserQuestionState) tea.Cmd {
output, err := buildAskUserQuestionToolResult(state)
if err != nil {
return func() tea.Msg {
return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err}
}
}
req := codersdk.SubmitToolResultsRequest{
Results: []codersdk.ToolResult{{
ToolCallID: state.ToolCallID,
Output: output,
IsError: false,
}},
}
return apiCmd(func() (struct{}, error) {
return struct{}{}, codersdk.NewExperimentalClient(client).SubmitToolResults(context.Background(), chatID, req)
}, func(_ struct{}, err error) tea.Msg {
return toolResultsSubmittedMsg{generation: generation, chatID: chatID, err: err}
})
}
func listenToStream(chatID uuid.UUID, generation uint64, eventCh <-chan codersdk.ChatStreamEvent) tea.Cmd {
return func() tea.Msg {
event, ok := <-eventCh
if !ok {
return chatStreamEventMsg{generation: generation, chatID: chatID, err: io.EOF}
}
return chatStreamEventMsg{generation: generation, chatID: chatID, event: event}
}
}
+152
View File
@@ -0,0 +1,152 @@
package cli_test
import (
"context"
"os"
"runtime"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
func expAgentsPtr[T any](v T) *T {
return &v
}
func setupExpAgentsBackend(t *testing.T) (*codersdk.Client, *codersdk.ExperimentalClient, uuid.UUID) {
t.Helper()
values := coderdtest.DeploymentValues(t)
values.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: values,
})
firstUser := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai",
APIKey: "test-api-key",
})
require.NoError(t, err)
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai",
Model: "gpt-4o-mini",
ContextLimit: expAgentsPtr(int64(4096)),
IsDefault: expAgentsPtr(true),
})
require.NoError(t, err)
return client, expClient, firstUser.OrganizationID
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func seedChat(t *testing.T, ctx context.Context, expClient *codersdk.ExperimentalClient, orgID uuid.UUID, seed string) codersdk.Chat {
t.Helper()
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: orgID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: seed,
},
},
})
require.NoError(t, err)
return chat
}
type expAgentsSession struct {
t *testing.T
pty *ptytest.PTY
errCh <-chan error
}
func (s *expAgentsSession) expect(ctx context.Context, text string) {
s.t.Helper()
s.pty.ExpectMatchContext(ctx, text)
}
func (s *expAgentsSession) wait(ctx context.Context) error {
s.t.Helper()
return testutil.RequireReceive(ctx, s.t, s.errCh)
}
//nolint:unused // Kept as a small PTY helper for future multi-character input.
func (s *expAgentsSession) write(text string) {
s.t.Helper()
s.pty.WriteLine(text)
}
func (s *expAgentsSession) writeRune(r rune) {
s.t.Helper()
_, err := s.pty.Input().Write([]byte(string(r)))
require.NoError(s.t, err)
}
func (s *expAgentsSession) enter() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\r"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) esc() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte("\x1b"))
require.NoError(s.t, err)
}
func (s *expAgentsSession) ctrlC() {
s.t.Helper()
_, err := s.pty.Input().Write([]byte{3})
require.NoError(s.t, err)
}
func (s *expAgentsSession) quit() {
s.t.Helper()
s.writeRune('q')
}
//nolint:revive // Test helper signature keeps t first for consistency with other helpers.
func startExpAgentsSession(t *testing.T, ctx context.Context, client *codersdk.Client, args ...string) *expAgentsSession {
t.Helper()
// Reading to / writing from the PTY is flaky on non-linux systems.
if runtime.GOOS != "linux" {
t.Skip("skipping on non-linux")
}
fullArgs := append([]string{"exp", "agents"}, args...)
inv, root := clitest.New(t, fullArgs...)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
tty, err := os.OpenFile(pty.Name(), os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() {
_ = tty.Close()
})
inv.Stdin = tty
inv.Stdout = tty
inv.Stderr = tty
errCh := make(chan error, 1)
tGo(t, func() {
errCh <- inv.WithContext(ctx).Run()
})
return &expAgentsSession{t: t, pty: pty, errCh: errCh}
}
+87
View File
@@ -0,0 +1,87 @@
package cli_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/testutil"
)
func TestExpAgentsE2E(t *testing.T) {
t.Parallel()
t.Run("EmptyStateBoot", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, _, _ := setupExpAgentsBackend(t)
session := startExpAgentsSession(t, ctx, client)
session.expect(ctx, "No chats yet. Press n to start a new chat.")
session.quit()
require.NoError(t, session.wait(ctx))
})
t.Run("ListAndNavigate", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
_ = seedChat(t, ctx, expClient, orgID, "alpha nav seed")
_ = seedChat(t, ctx, expClient, orgID, "bravo nav seed")
_ = seedChat(t, ctx, expClient, orgID, "charlie nav seed")
session := startExpAgentsSession(t, ctx, client)
session.expect(ctx, "charlie nav seed")
session.expect(ctx, "enter: open")
session.enter()
session.expect(ctx, "esc")
session.esc()
session.expect(ctx, "enter: open")
session.quit()
require.NoError(t, session.wait(ctx))
})
t.Run("SearchFilter", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
_ = seedChat(t, ctx, expClient, orgID, "alpha filter seed")
_ = seedChat(t, ctx, expClient, orgID, "zulu filter seed")
session := startExpAgentsSession(t, ctx, client)
session.expect(ctx, "alpha filter seed")
session.expect(ctx, "enter: open")
session.writeRune('/')
session.expect(ctx, "/ ")
for _, r := range "zzzznotamatch" {
session.writeRune(r)
}
session.expect(ctx, "No matches.")
session.ctrlC()
require.NoError(t, session.wait(ctx))
})
t.Run("ExistingChatHistory", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, expClient, orgID := setupExpAgentsBackend(t)
chat := seedChat(t, ctx, expClient, orgID, "direct open seed")
session := startExpAgentsSession(t, ctx, client, chat.ID.String())
session.expect(ctx, "direct open seed")
session.expect(ctx, "esc")
session.esc()
session.expect(ctx, "enter: open")
session.quit()
require.NoError(t, session.wait(ctx))
})
}
+33
View File
@@ -0,0 +1,33 @@
package cli
import (
"regexp"
"strings"
"unicode"
)
var terminalEscapeSequenceRegexp = regexp.MustCompile(
`\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` +
"›" + `[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|` +
`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|` +
"" + `[^\x07\x1b]*(?:\x07|\x1b\\)|` +
`\x1b[^\[\]].`,
)
func sanitizeTerminalRenderableText(text string) string {
if text == "" {
return ""
}
text = terminalEscapeSequenceRegexp.ReplaceAllString(text, "")
return strings.Map(func(r rune) rune {
switch r {
case '\n', '\t':
return r
}
if unicode.IsControl(r) {
return -1
}
return r
}, text)
}
+482
View File
@@ -0,0 +1,482 @@
package cli
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
type (
openSelectedChatMsg struct {
chatID uuid.UUID
}
openDraftChatMsg struct{}
refreshChatsMsg struct{}
)
type chatDisplayRow struct {
chat codersdk.Chat
depth int
isSubagent bool
childCount int
isExpanded bool
}
type chatListModel struct {
styles tuiStyles
chats []codersdk.Chat
expanded map[uuid.UUID]bool
cursor int
offset int
loading bool
err error
search textinput.Model
searching bool
spinner spinner.Model
width int
height int
}
func newChatListModel(styles tuiStyles) chatListModel {
search := textinput.New()
search.Placeholder = "Search chats..."
search.Prompt = "/ "
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = styles.dimmedText
return chatListModel{
styles: styles,
expanded: make(map[uuid.UUID]bool),
loading: true,
search: search,
spinner: s,
}
}
func (m chatListModel) searchQuery() string {
return strings.TrimSpace(strings.ToLower(m.search.Value()))
}
func (m chatListModel) filteredChats() []codersdk.Chat {
query := m.searchQuery()
if query == "" {
return m.chats
}
filtered := make([]codersdk.Chat, 0, len(m.chats))
for _, chat := range m.chats {
if strings.Contains(strings.ToLower(chat.Title), query) || strings.Contains(strings.ToLower(chat.ID.String()), query) {
filtered = append(filtered, chat)
continue
}
if chat.LastError != nil && strings.Contains(strings.ToLower(*chat.LastError), query) {
filtered = append(filtered, chat)
}
}
return filtered
}
func (m chatListModel) displayRows() []chatDisplayRow {
filtered := m.filteredChats()
if len(filtered) == 0 {
return nil
}
queryActive := m.searchQuery() != ""
chatsByID := make(map[uuid.UUID]codersdk.Chat, len(m.chats))
included := make(map[uuid.UUID]struct{}, len(filtered))
for _, chat := range m.chats {
chatsByID[chat.ID] = chat
}
for _, chat := range filtered {
included[chat.ID] = struct{}{}
if !queryActive {
continue
}
for parentID := chat.ParentChatID; parentID != nil; {
parent, ok := chatsByID[*parentID]
if !ok {
break
}
included[parent.ID] = struct{}{}
parentID = parent.ParentChatID
}
}
childrenOf := make(map[uuid.UUID][]codersdk.Chat)
roots := make([]codersdk.Chat, 0, len(included))
for _, chat := range m.chats {
if _, ok := included[chat.ID]; !ok {
continue
}
if chat.ParentChatID == nil {
roots = append(roots, chat)
continue
}
if _, ok := included[*chat.ParentChatID]; ok {
childrenOf[*chat.ParentChatID] = append(childrenOf[*chat.ParentChatID], chat)
}
}
rows := make([]chatDisplayRow, 0, len(included))
var appendRows func(codersdk.Chat, int)
appendRows = func(chat codersdk.Chat, depth int) {
children := childrenOf[chat.ID]
isExpanded := m.expanded[chat.ID]
if queryActive && len(children) > 0 {
isExpanded = true
}
rows = append(rows, chatDisplayRow{
chat: chat,
depth: depth,
isSubagent: depth > 0,
childCount: len(children),
isExpanded: isExpanded,
})
if !isExpanded {
return
}
for _, child := range children {
appendRows(child, depth+1)
}
}
for _, root := range roots {
appendRows(root, 0)
}
return rows
}
func (m chatListModel) selectedRow() (chatDisplayRow, bool) {
rows := m.displayRows()
if len(rows) == 0 || m.cursor < 0 || m.cursor >= len(rows) {
return chatDisplayRow{}, false
}
return rows[m.cursor], true
}
func (m *chatListModel) moveCursorToChat(chatID uuid.UUID) {
rows := m.displayRows()
for i, row := range rows {
if row.chat.ID == chatID {
m.cursor = i
return
}
}
}
type chatExpansionIntent int
const (
chatExpansionToggle chatExpansionIntent = iota
chatExpansionExpand
chatExpansionCollapse
)
func (m *chatListModel) updateSelectedRowExpansion(intent chatExpansionIntent) bool {
row, ok := m.selectedRow()
if !ok {
return false
}
if row.childCount == 0 {
if intent == chatExpansionExpand || row.chat.ParentChatID == nil {
return false
}
parentID := *row.chat.ParentChatID
m.expanded[parentID] = false
m.moveCursorToChat(parentID)
return true
}
switch intent {
case chatExpansionExpand:
if row.isExpanded {
return false
}
m.expanded[row.chat.ID] = true
case chatExpansionCollapse:
if row.isExpanded {
m.expanded[row.chat.ID] = false
return true
}
if row.chat.ParentChatID == nil || !m.expanded[*row.chat.ParentChatID] {
return false
}
parentID := *row.chat.ParentChatID
m.expanded[parentID] = false
m.moveCursorToChat(parentID)
return true
case chatExpansionToggle:
if row.isExpanded && !m.expanded[row.chat.ID] {
return false
}
m.expanded[row.chat.ID] = !row.isExpanded
default:
return false
}
return true
}
func (m chatListModel) selectedChat() *codersdk.Chat {
row, ok := m.selectedRow()
if !ok {
return nil
}
return &row.chat
}
func (m *chatListModel) normalizeCursor() {
total := len(m.displayRows())
if total == 0 {
m.cursor = 0
m.offset = 0
return
}
m.cursor = min(max(m.cursor, 0), total-1)
m.offset, _ = m.visibleWindow(total)
}
func (m chatListModel) visibleChatCount() int {
overhead := 3
if m.searching {
overhead += 2
}
visibleCount := m.height - overhead
if visibleCount < 3 {
visibleCount = 3
}
return visibleCount
}
func (m chatListModel) visibleWindow(total int) (start int, end int) {
if total == 0 {
return 0, 0
}
visibleCount := m.visibleChatCount()
maxOffset := max(total-visibleCount, 0)
cursor := min(max(m.cursor, 0), total-1)
start = min(max(min(max(m.offset, 0), maxOffset), cursor-visibleCount+1), cursor)
end = min(start+visibleCount, total)
return start, end
}
func (m chatListModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m chatListModel) Update(msg tea.Msg) (chatListModel, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.normalizeCursor()
return m, nil
case spinner.TickMsg:
if m.loading {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
case chatsListedMsg:
m.chats = msg.chats
m.err = msg.err
m.loading = false
m.normalizeCursor()
return m, nil
case tea.KeyMsg:
key := msg.String()
if m.searching {
switch key {
case "esc":
if m.search.Value() != "" {
m.search.SetValue("")
}
m.search.Blur()
m.searching = false
m.normalizeCursor()
return m, nil
case "enter":
m.search.Blur()
m.searching = false
m.normalizeCursor()
return m, nil
default:
m.search, cmd = m.search.Update(msg)
m.normalizeCursor()
m.offset = 0
return m, cmd
}
}
navigationHandled, normalizeNavigation := true, true
switch key {
case "/", "ctrl+f":
m.searching = true
m.search.Focus()
case "up", "k":
m.cursor--
case "down", "j":
m.cursor++
case "right", "l":
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionExpand)
case "left", "h":
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionCollapse)
case "x":
normalizeNavigation = m.updateSelectedRowExpansion(chatExpansionToggle)
default:
navigationHandled = false
}
if navigationHandled {
if normalizeNavigation {
m.normalizeCursor()
}
return m, nil
}
switch key {
case "enter":
selected := m.selectedChat()
if selected == nil {
return m, nil
}
return m, func() tea.Msg {
return openSelectedChatMsg{chatID: selected.ID}
}
case "n":
return m, func() tea.Msg {
return openDraftChatMsg{}
}
case "r":
m.loading = true
m.err = nil
return m, func() tea.Msg {
return refreshChatsMsg{}
}
case "q":
return m, tea.Quit
}
}
return m, nil
}
func (m chatListModel) View() string {
if m.loading {
return m.spinner.View() + " Loading chats…"
}
if m.err != nil {
return m.styles.errorText.Render(m.err.Error()) + "\n" + m.styles.helpText.Render("Press r to retry")
}
rows := m.displayRows()
lines := make([]string, 0, len(rows)+3)
if m.searching {
lines = append(lines, m.styles.searchInput.Render(m.search.View()))
}
if len(rows) == 0 {
if strings.TrimSpace(m.search.Value()) != "" {
lines = append(lines, m.styles.dimmedText.Render("No matches."))
} else {
lines = append(lines, m.styles.dimmedText.Render("No chats yet. Press n to start a new chat."))
}
help := fitHelpText(
m.width,
"/: search • n: new chat • r: refresh • q: quit",
"/ search • n new • r refresh • q quit",
"/ • n • r • q",
)
lines = append(lines, m.styles.helpText.Render(help))
return strings.Join(lines, "\n")
}
statusWidth := 12
start, end := m.visibleWindow(len(rows))
for i := start; i < end; i++ {
row := rows[i]
rowPrefix := " "
rowStyle := m.styles.normalItem
if i == m.cursor {
rowPrefix = "> "
rowStyle = m.styles.selectedItem
}
if row.depth > 0 {
rowPrefix += strings.Repeat(" ", row.depth)
}
if row.childCount > 0 {
if row.isExpanded {
rowPrefix += "▼ "
} else {
rowPrefix += "▶ "
}
}
extraText := ""
extra := ""
if row.childCount > 0 {
extraText = fmt.Sprintf(" (%d subagents)", row.childCount)
extra = m.styles.dimmedText.Render(extraText)
}
titleWidth := max(m.width-statusWidth-18-len(rowPrefix)-len(extraText), 20)
title := m.styles.truncate(sanitizeTerminalRenderableText(row.chat.Title), titleWidth)
status := m.styles.statusColor(row.chat.Status).Render(string(row.chat.Status))
rowText := fmt.Sprintf("%s%s %s %s%s", rowPrefix, rowStyle.Render(title), status, m.styles.dimmedText.Render(timeAgo(row.chat.UpdatedAt)), extra)
lines = append(lines, rowText)
if row.chat.Status == codersdk.ChatStatusError && row.chat.LastError != nil {
errWidth := max(m.width-4, 20)
errPrefix := " "
if row.depth > 0 {
errPrefix += strings.Repeat(" ", row.depth)
}
lines = append(lines, errPrefix+m.styles.dimmedText.Render(m.styles.truncate(sanitizeTerminalRenderableText(*row.chat.LastError), errWidth)))
}
}
lines = append(lines, "")
help := fitHelpText(
m.width,
"↑/k: up • ↓/j: down • →/l: expand • ←/h: collapse • x: toggle • enter: open • /: search • n: new chat • r: refresh • q: quit",
"↑/k up • ↓/j down • →/l expand • ←/h collapse • x toggle • ↵ open • / search • n new • q quit",
"↑↓ nav • →← fold • x toggle • ↵ open • / search • n new • q quit",
"↑↓ • →← • x • ↵ • / • n • q",
)
lines = append(lines, m.styles.helpText.Render(help))
return strings.Join(lines, "\n")
}
func timeAgo(t time.Time) string {
elapsed := time.Since(t)
if elapsed < time.Minute {
return "just now"
}
if elapsed < time.Hour {
return fmt.Sprintf("%dm ago", int(elapsed/time.Minute))
}
if elapsed < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(elapsed/time.Hour))
}
return fmt.Sprintf("%dd ago", int(elapsed/(24*time.Hour)))
}
+516
View File
@@ -0,0 +1,516 @@
package cli
import (
"context"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"github.com/coder/coder/v2/codersdk"
)
type tuiView int
const (
viewList tuiView = iota
viewChat
)
type tuiOverlay int
const (
overlayNone tuiOverlay = iota
overlayModelPicker
overlayDiffDrawer
overlayAskUserQuestion
)
type (
terminateTUIMsg struct{}
expChatsTUIModel struct {
ctx context.Context
client *codersdk.ExperimentalClient
styles tuiStyles
currentView tuiView
overlay tuiOverlay
list chatListModel
chat chatViewModel
initialChatID *uuid.UUID
workspaceID *uuid.UUID
modelOverride *string
organizationID uuid.UUID
chatGeneration uint64
catalog *codersdk.ChatModelsResponse
quitting bool
width int
height int
}
)
func newExpChatsTUIModel(
ctx context.Context,
client *codersdk.ExperimentalClient,
initialChatID *uuid.UUID,
workspaceID *uuid.UUID,
modelOverride *string,
organizationID uuid.UUID,
) expChatsTUIModel {
styles := newTUIStyles()
currentView := viewList
if initialChatID != nil {
currentView = viewChat
}
chat := newChatViewModel(ctx, client, workspaceID, modelOverride, organizationID, styles)
chatGeneration := uint64(0)
if initialChatID != nil {
chat.activeChatID = *initialChatID
chat.chatGeneration = 1
chat.loading = true
chat.metadataResolved = false
chat.historyResolved = false
chatGeneration = 1
}
return expChatsTUIModel{
ctx: ctx,
client: client,
styles: styles,
currentView: currentView,
overlay: overlayNone,
list: newChatListModel(styles),
chat: chat,
initialChatID: initialChatID,
workspaceID: workspaceID,
modelOverride: modelOverride,
organizationID: organizationID,
chatGeneration: chatGeneration,
}
}
// resetChatSession creates a fresh chatViewModel, preserves the
// window dimensions from the previous session, and advances
// the monotonic generation counter so in-flight async messages
// from the old session are ignored.
func (m *expChatsTUIModel) resetChatSession() {
old := m.chat
m.chat = newChatViewModel(m.ctx, m.client, m.workspaceID, m.modelOverride, m.organizationID, m.styles)
m.chat.width = old.width
m.chat.height = old.height
m.chat.loading = true
m.chat.metadataResolved = false
m.chat.historyResolved = false
m.chatGeneration++
m.chat.chatGeneration = m.chatGeneration
}
func (m *expChatsTUIModel) setRenderer(renderer *lipgloss.Renderer) {
styles := newTUIStyles(renderer)
m.styles = styles
m.list.styles = styles
m.list.spinner.Style = styles.dimmedText
m.chat.styles = styles
m.chat.spinner.Style = styles.dimmedText
}
func (m expChatsTUIModel) Init() tea.Cmd {
if m.initialChatID != nil {
m.chat.activeChatID = *m.initialChatID
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*m.initialChatID, m.chat.chatGeneration)...)...)
}
return tea.Batch(m.loadChatsCmd(), m.list.Init())
}
func (m expChatsTUIModel) loadChatsCmd() tea.Cmd {
return apiCmd(func() ([]codersdk.Chat, error) { return m.client.ListChats(m.ctx, nil) }, func(chats []codersdk.Chat, err error) tea.Msg { return chatsListedMsg{chats: chats, err: err} })
}
func (m expChatsTUIModel) loadChatCmd(chatID uuid.UUID, generation uint64) []tea.Cmd {
return []tea.Cmd{apiCmd(func() (codersdk.Chat, error) { return m.client.GetChat(m.ctx, chatID) }, func(chat codersdk.Chat, err error) tea.Msg {
return chatOpenedMsg{generation: generation, chatID: chatID, chat: chat, err: err}
}), loadChatHistoryCmd(m.ctx, m.client, chatID, generation)}
}
func (m expChatsTUIModel) childWindowSizeMsg() tea.WindowSizeMsg {
h := m.height
if m.currentView == viewList {
h = max(0, h-1)
}
return tea.WindowSizeMsg{Width: m.width, Height: h}
}
func (m *expChatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
if m.overlay == overlay {
m.overlay = overlayNone
return false
}
m.overlay = overlay
return true
}
func (m *expChatsTUIModel) handleEsc(msg tea.KeyMsg) tea.Cmd {
if m.currentView == viewList && m.list.searching {
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return cmd
}
if m.currentView == viewChat {
m.chatGeneration++
m.chat.chatGeneration = m.chatGeneration
m.chat.stopStream()
m.currentView = viewList
m.list.loading = true
return m.loadChatsCmd()
}
m.quitting = true
return tea.Quit
}
func isOverlayCloseKey(msg tea.KeyMsg) bool {
if msg.Type == tea.KeyEsc || msg.Type == tea.KeyEscape {
return true
}
key := msg.String()
return key == "esc" || key == "ctrl+["
}
func (m *expChatsTUIModel) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "up", "k":
if m.chat.modelPickerCursor > 0 {
m.chat.modelPickerCursor--
}
case "down", "j":
if m.chat.modelPickerCursor < len(m.chat.modelPickerFlat)-1 {
m.chat.modelPickerCursor++
}
case "enter":
if len(m.chat.modelPickerFlat) > 0 && m.chat.modelPickerCursor < len(m.chat.modelPickerFlat) {
selected := m.chat.modelPickerFlat[m.chat.modelPickerCursor]
m.chat.modelOverride = &selected.ID
m.modelOverride = &selected.ID
m.overlay = overlayNone
}
case "ctrl+p", "q":
m.overlay = overlayNone
}
return nil
}
func (m *expChatsTUIModel) handleAskUserQuestionKey(msg tea.KeyMsg) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || state.Submitting || len(state.Questions) == 0 {
return nil
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return nil
}
if state.OtherMode {
switch msg.Type {
case tea.KeyEsc:
state.OtherMode = false
state.OtherInput.Blur()
return nil
case tea.KeyEnter:
answer := strings.TrimSpace(state.OtherInput.Value())
if answer == "" {
return nil
}
return m.recordAskAnswer(answer, "", true)
default:
var cmd tea.Cmd
state.OtherInput, cmd = state.OtherInput.Update(msg)
return cmd
}
}
question := state.Questions[state.CurrentIndex]
optionCount := len(question.Options) + 1
switch msg.String() {
case "up", "k":
state.OptionCursor--
if state.OptionCursor < 0 {
state.OptionCursor = optionCount - 1
}
case "down", "j":
state.OptionCursor++
if state.OptionCursor >= optionCount {
state.OptionCursor = 0
}
case "left", "h":
if state.CurrentIndex == 0 {
return nil
}
state.CurrentIndex--
state.OptionCursor = 0
state.OtherMode = false
state.OtherInput.Blur()
state.Error = nil
if len(state.Answers) > state.CurrentIndex {
state.Answers = state.Answers[:state.CurrentIndex]
}
case "enter":
state.Error = nil
if state.OptionCursor < len(question.Options) {
option := question.Options[state.OptionCursor]
answer := strings.TrimSpace(option.Value)
if answer == "" {
answer = option.Label
}
return m.recordAskAnswer(answer, option.Label, false)
}
state.OtherMode = true
state.OtherInput.SetValue("")
state.OtherInput.Focus()
}
return nil
}
func (m *expChatsTUIModel) recordAskAnswer(answer, optionLabel string, freeform bool) tea.Cmd {
state := m.chat.pendingAskUserQuestion
if state == nil || len(state.Questions) == 0 {
return nil
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return nil
}
question := state.Questions[state.CurrentIndex]
if len(state.Answers) > state.CurrentIndex {
state.Answers = state.Answers[:state.CurrentIndex]
}
state.Answers = append(state.Answers, askQuestionAnswer{
Header: question.Header,
Question: question.Question,
Answer: answer,
OptionLabel: optionLabel,
Freeform: freeform,
})
state.OtherMode = false
state.OtherInput.Blur()
state.OtherInput.SetValue("")
state.OptionCursor = 0
state.Error = nil
if state.CurrentIndex+1 < len(state.Questions) {
state.CurrentIndex++
return nil
}
state.Submitting = true
return submitAskUserQuestionCmd(m.client.Client, m.chat.activeChatID, m.chat.chatGeneration, state)
}
func (m *expChatsTUIModel) openChatCmd(chatID *uuid.UUID) tea.Cmd {
m.currentView = viewChat
m.chat.stopStream()
m.resetChatSession()
if chatID == nil {
m.chat.draft = true
m.chat.loading = false
m.chat.metadataResolved = true
m.chat.historyResolved = true
m.chat, _ = m.chat.Update(m.childWindowSizeMsg())
return nil
}
m.chat.activeChatID = *chatID
m.chat, _ = m.chat.Update(m.childWindowSizeMsg())
return tea.Batch(append([]tea.Cmd{m.chat.Init()}, m.loadChatCmd(*chatID, m.chat.chatGeneration)...)...)
}
func (m *expChatsTUIModel) toggleModelPickerCmd() tea.Cmd {
if !m.toggleOverlay(overlayModelPicker) {
return nil
}
if m.catalog == nil {
return apiCmd(func() (codersdk.ChatModelsResponse, error) { return m.client.ListChatModels(m.ctx) }, func(catalog codersdk.ChatModelsResponse, err error) tea.Msg {
return modelsListedMsg{catalog: catalog, err: err}
})
}
if len(m.chat.modelPickerFlat) == 0 {
m.chat.modelPickerFlat = availableChatModels(*m.catalog)
}
return nil
}
func (m *expChatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
if m.chat.chat == nil {
return nil
}
if !m.toggleOverlay(overlayDiffDrawer) {
return nil
}
if m.chat.gitChanges == nil || m.chat.diffContents == nil || m.chat.diffErr != nil {
m.chat.diffErr = nil
chatID := m.chat.chat.ID
generation := m.chat.chatGeneration
return tea.Batch(apiCmd(func() ([]codersdk.ChatGitChange, error) { return m.client.GetChatGitChanges(m.ctx, chatID) }, func(changes []codersdk.ChatGitChange, err error) tea.Msg {
return gitChangesMsg{generation: generation, chatID: chatID, changes: changes, err: err}
}), apiCmd(func() (codersdk.ChatDiffContents, error) { return m.client.GetChatDiffContents(m.ctx, chatID) }, func(diff codersdk.ChatDiffContents, err error) tea.Msg {
return diffContentsMsg{generation: generation, chatID: chatID, diff: diff, err: err}
}))
}
return nil
}
func (m expChatsTUIModel) updateChild(msg tea.Msg, view tuiView) (expChatsTUIModel, tea.Cmd) {
var cmd tea.Cmd
if view == viewChat {
m.chat, cmd = m.chat.Update(msg)
} else {
m.list, cmd = m.list.Update(msg)
}
return m, cmd
}
func (m expChatsTUIModel) renderOverlay(title, body string) string {
return renderOverlayFrame(m.styles, m.width, m.styles.title.Render(title), body, m.styles.helpText.Render("Esc to close"))
}
func (m expChatsTUIModel) diffOverlayView() string {
switch {
case m.chat.diffErr != nil:
return m.renderOverlay("Diff", m.styles.errorText.Render(wrapPreservingNewlines(m.chat.diffErr.Error(), contentWidth(m.width, 6))))
case m.chat.diffContents != nil:
return renderDiffDrawer(m.styles, *m.chat.diffContents, m.chat.gitChanges, m.width, m.height)
default:
return m.renderOverlay("Diff", m.styles.dimmedText.Render("Loading diff…"))
}
}
func padViewHeight(text string, height int) string {
if height <= 0 {
return text
}
if text == "" {
return strings.Repeat("\n", max(height-1, 0))
}
lineCount := countRenderedLines(text)
if lineCount >= height {
return text
}
return text + strings.Repeat("\n", height-lineCount)
}
func (m expChatsTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
childMsg := m.childWindowSizeMsg()
m.list, _ = m.list.Update(childMsg)
m.chat, _ = m.chat.Update(childMsg)
return m, nil
case terminateTUIMsg:
m.quitting = true
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
// Handle overlays first so their keys do not leak to the underlying
// view.
if m.overlay == overlayAskUserQuestion {
return m, m.handleAskUserQuestionKey(msg)
}
if m.overlay == overlayModelPicker {
if isOverlayCloseKey(msg) {
m.overlay = overlayNone
return m, tea.ClearScreen
}
cmd := m.handleModelPickerKey(msg)
if m.overlay == overlayNone {
return m, tea.Batch(cmd, tea.ClearScreen)
}
return m, cmd
}
if m.overlay == overlayDiffDrawer {
if isOverlayCloseKey(msg) {
m.overlay = overlayNone
return m, tea.ClearScreen
}
return m, nil
}
if msg.String() == "esc" {
return m, m.handleEsc(msg)
}
case openSelectedChatMsg:
return m, m.openChatCmd(&msg.chatID)
case openDraftChatMsg:
return m, m.openChatCmd(nil)
case refreshChatsMsg:
return m, m.loadChatsCmd()
case toggleModelPickerMsg:
return m, m.toggleModelPickerCmd()
case toggleDiffDrawerMsg:
return m, m.toggleDiffDrawerCmd()
case showAskUserQuestionMsg:
m.chat.pendingAskUserQuestion = msg.state
m.overlay = overlayAskUserQuestion
return m.updateChild(msg, viewChat)
case hideAskUserQuestionMsg:
if m.overlay == overlayAskUserQuestion {
m.overlay = overlayNone
}
return m.updateChild(msg, viewChat)
case toolResultsSubmittedMsg:
if msg.err == nil && m.chat.matchesGeneration(msg.generation) && msg.chatID == m.chat.activeChatID {
m.chat.pendingAskUserQuestion = nil
if m.overlay == overlayAskUserQuestion {
m.overlay = overlayNone
}
}
return m.updateChild(msg, viewChat)
case chatsListedMsg:
return m.updateChild(msg, viewList)
case chatOpenedMsg, chatHistoryMsg, chatStreamEventMsg, messageSentMsg, chatCreatedMsg, chatInterruptedMsg, gitChangesMsg, diffContentsMsg:
return m.updateChild(msg, viewChat)
case modelsListedMsg:
if msg.err != nil {
m.overlay = overlayNone
} else {
catalog := msg.catalog
m.catalog = &catalog
}
return m.updateChild(msg, viewChat)
}
return m.updateChild(msg, m.currentView)
}
func (m expChatsTUIModel) View() string {
if m.quitting {
return ""
}
var base string
if m.currentView == viewChat {
base = m.chat.View()
} else {
base = m.styles.title.Render("Coder Chats") + "\n" + m.list.View()
}
switch m.overlay {
case overlayAskUserQuestion:
if m.chat.pendingAskUserQuestion != nil {
base += "\n" + renderAskUserQuestion(m.styles, m.chat.pendingAskUserQuestion, m.width, m.height)
}
case overlayModelPicker:
if m.catalog == nil {
base += "\n" + m.renderOverlay("Select Model", m.styles.dimmedText.Render("Loading models..."))
break
}
selectedID := ""
if m.chat.modelOverride != nil {
selectedID = *m.chat.modelOverride
}
base += "\n" + renderModelPicker(m.styles, *m.catalog, selectedID, m.chat.modelPickerCursor, m.width, m.height)
case overlayDiffDrawer:
base += "\n" + m.diffOverlayView()
}
return padViewHeight(base, m.height)
}
+883
View File
@@ -0,0 +1,883 @@
package cli
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/coder/coder/v2/codersdk"
)
const (
contextCompactionToolName = "context_compaction"
toolBlockIndent = " "
toolDetailIndent = " "
toolSummaryFallbackWidth = 48
pendingToolIcon = "○"
reasoningPrefix = "thinking: "
)
func compactTranscriptJSON(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return ""
}
var builder bytes.Buffer
if err := json.Compact(&builder, raw); err == nil {
return builder.String()
}
return string(raw)
}
func toolBaseName(name string) string {
name = strings.TrimSpace(name)
name = strings.TrimPrefix(name, "coder_")
name = strings.TrimPrefix(name, "github__")
return strings.Join(strings.Fields(name), " ")
}
func humanizeToolName(name string) string {
name = strings.ReplaceAll(toolBaseName(name), "_", " ")
name = strings.Join(strings.Fields(name), " ")
if name == "" {
return "tool"
}
return name
}
func normalizeToolName(name string) string {
if toolBaseName(name) == "" {
return ""
}
return strings.ReplaceAll(strings.ToLower(humanizeToolName(name)), " ", "_")
}
func summarizeToolContent(toolName, raw string, fields ...string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parsed any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil {
if summary := toolObjectSummary(toolName, parsed); summary != "" {
return summary
}
if value := firstStringField(parsed, fields...); value != "" {
return strconv.Quote(value)
}
if value := firstShortStringValue(parsed); value != "" {
return strconv.Quote(value)
}
}
compact := compactTranscriptJSON(json.RawMessage(raw))
if compact == "" {
return ""
}
compactRunes := []rune(compact)
if len(compactRunes) <= toolSummaryFallbackWidth {
return compact
}
return string(compactRunes[:toolSummaryFallbackWidth-1]) + "…"
}
var toolArgsSummary = summarizeToolContent
func toolResultSummary(toolName, argsJSON, resultJSON string) string {
return cmp.Or(
summarizeToolContent(toolName, argsJSON),
summarizeToolContent(toolName, resultJSON),
"null",
)
}
func toolObjectSummary(toolName string, parsed any) string {
normalized := normalizeToolName(toolName)
switch {
case normalized == "execute" || normalized == "execute_command" || normalized == "run_command":
if command := firstStringField(parsed, "command", "cmd", "script", "input"); command != "" {
return strconv.Quote(command)
}
case strings.Contains(normalized, "read_file") || strings.Contains(normalized, "write_file") || strings.Contains(normalized, "delete_file") || strings.Contains(normalized, "stat_file"):
if path := firstStringField(parsed, "path", "file_path", "filename"); path != "" {
return "(" + path + ")"
}
case normalized == "get_pull_request":
owner := firstStringField(parsed, "owner")
repo := firstStringField(parsed, "repo", "repository")
switch {
case owner != "" && repo != "":
return "(" + owner + "/" + repo + ")"
case repo != "":
return "(" + repo + ")"
}
case strings.Contains(normalized, "workspace"):
if workspace := firstStringField(parsed, "workspace_name", "name", "workspace"); workspace != "" {
return "(" + workspace + ")"
}
}
return ""
}
func firstStringField(value any, keys ...string) string {
object, ok := value.(map[string]any)
if !ok {
return ""
}
for _, key := range keys {
fieldValue, ok := object[key]
if !ok {
continue
}
if text := firstShortStringValue(fieldValue); text != "" {
return text
}
}
return ""
}
func firstShortStringValue(value any) string {
switch typed := value.(type) {
case string:
trimmed := strings.Join(strings.Fields(strings.TrimSpace(typed)), " ")
if trimmed == "" {
return ""
}
return trimmed
case []any:
for _, item := range typed {
if text := firstShortStringValue(item); text != "" {
return text
}
}
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
if text := firstShortStringValue(typed[key]); text != "" {
return text
}
}
}
return ""
}
func toolDisplayLabel(toolName string, kind chatBlockKind, collapsedCount int) string {
label := humanizeToolName(toolName)
if collapsedCount <= 1 {
return label
}
switch kind {
case blockToolCall:
return label + "..."
case blockToolResult:
return fmt.Sprintf("%s (x%d)", label, collapsedCount)
default:
return label
}
}
func renderToolLine(styles tuiStyles, labelStyle lipgloss.Style, icon, label, summary string, width int) string {
label = sanitizeTerminalRenderableText(label)
summary = sanitizeTerminalRenderableText(summary)
header := toolBlockIndent + labelStyle.Render(icon) + " " + label
if summary == "" || width <= 0 {
return header
}
available := width - lipgloss.Width(header) - 1
preview := styles.truncate(summary, max(available, 0))
if preview == "" {
return header
}
return header + " " + styles.dimmedText.Render(preview)
}
func renderToolDetail(styles tuiStyles, label, value string, width int) string {
value = sanitizeTerminalRenderableText(value)
if strings.TrimSpace(value) == "" {
return ""
}
prefix := toolDetailIndent + label + ": "
wrapped := wrapPreservingNewlines(value, contentWidth(width, lipgloss.Width(prefix)))
lines := strings.Split(wrapped, "\n")
for i := range lines {
if i == 0 {
lines[i] = prefix + lines[i]
continue
}
lines[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + lines[i]
}
return styles.dimmedText.Render(strings.Join(lines, "\n"))
}
func renderExpandedToolBlock(styles tuiStyles, labelStyle lipgloss.Style, icon, toolName, args, result string, width int) string {
lines := []string{toolBlockIndent + labelStyle.Render(icon) + " " + humanizeToolName(toolName)}
if argsLine := renderToolDetail(styles, "args", args, width); argsLine != "" {
lines = append(lines, argsLine)
}
if resultLine := renderToolDetail(styles, "result", result, width); resultLine != "" {
lines = append(lines, resultLine)
}
return strings.Join(lines, "\n")
}
func toolResultIconAndStyle(styles tuiStyles, block chatBlock) (string, lipgloss.Style) {
if block.isError {
return "✗", styles.errorText
}
return "✓", styles.toolSuccess
}
func renderToolCallBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
return renderToolLine(
styles,
styles.toolPending,
pendingToolIcon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summarizeToolContent(block.toolName, block.args),
width,
)
}
func renderToolResultBlock(styles tuiStyles, block chatBlock, width int) string {
if block.toolName == contextCompactionToolName {
return renderCompaction(styles, width)
}
icon, labelStyle := toolResultIconAndStyle(styles, block)
summary := summarizeToolContent(block.toolName, block.args)
if summary == "" && block.isError {
summary = summarizeToolContent("", block.result, "error", "message", "detail", "stderr")
}
if summary == "" {
summary = toolResultSummary(block.toolName, "", block.result)
}
return renderToolLine(
styles,
labelStyle,
icon,
toolDisplayLabel(block.toolName, block.kind, block.collapsedCount),
summary,
width,
)
}
func renderCompaction(styles tuiStyles, width int) string {
banner := styles.compaction.Render("🗜️ Context compacted")
if width <= 0 {
return banner
}
return lipgloss.PlaceHorizontal(width, lipgloss.Center, banner)
}
func contentWidth(width, inset int) int {
if width <= 0 {
return 80
}
return max(width-inset, 1)
}
func renderOverlayFrame(styles tuiStyles, width int, sections ...string) string {
sections = slices.DeleteFunc(sections, func(section string) bool { return section == "" })
return styles.overlayBorder.Width(contentWidth(width, 6)).Render(strings.Join(sections, "\n\n"))
}
func diffMetadataLines(diff codersdk.ChatDiffContents) []string {
var lines []string
if diff.Branch != nil && *diff.Branch != "" {
lines = append(lines, fmt.Sprintf("Branch: %s", *diff.Branch))
}
if diff.PullRequestURL != nil && *diff.PullRequestURL != "" {
lines = append(lines, fmt.Sprintf("PR: %s", *diff.PullRequestURL))
}
return lines
}
func renderChatDiffSummary(diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange) string {
lines := diffMetadataLines(diff)
if len(changes) == 0 {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "No changes detected.")
return strings.Join(lines, "\n")
}
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "Files changed:")
for _, change := range changes {
path := sanitizeTerminalRenderableText(change.FilePath)
if change.ChangeType == "renamed" && change.OldPath != nil && *change.OldPath != "" {
path = fmt.Sprintf("%s → %s", sanitizeTerminalRenderableText(*change.OldPath), path)
}
lines = append(lines, fmt.Sprintf(" %-8s %s", change.ChangeType, path))
}
return strings.Join(lines, "\n")
}
func renderDiffDrawer(styles tuiStyles, diff codersdk.ChatDiffContents, changes []codersdk.ChatGitChange, width, height int) string {
innerWidth := contentWidth(width, 6)
headerBits := []string{styles.title.Render("Diff")}
if meta := diffMetadataLines(diff); len(meta) > 0 {
headerBits = append(headerBits, styles.subtitle.Render(strings.Join(meta, " • ")))
}
summary := renderChatDiffSummary(diff, changes)
diffBody := sanitizeTerminalRenderableText(diff.Diff)
if strings.TrimSpace(diffBody) == "" {
diffBody = styles.dimmedText.Render("No diff contents.")
}
help := styles.helpText.Render("Esc to close")
overhead := countRenderedLines(strings.Join(headerBits, "\n")) + countRenderedLines(summary) + countRenderedLines(help) + 4
availableBodyLines := max(height-overhead, 0)
if height <= 0 {
availableBodyLines = 12
}
wrappedDiff := wrapPreservingNewlines(diffBody, innerWidth)
if availableBodyLines == 0 {
wrappedDiff = ""
} else {
wrappedDiff = clampLines(wrappedDiff, availableBodyLines)
}
return renderOverlayFrame(styles, width, strings.Join(headerBits, "\n"), summary, wrappedDiff, help)
}
func renderModelPicker(styles tuiStyles, catalog codersdk.ChatModelsResponse, selected string, cursor int, width, height int) string {
innerWidth := contentWidth(width, 6)
lines := []string{styles.title.Render("Select Model")}
cursorLine := 0
hasModels := false
flatIndex := 0
for _, provider := range catalog.Providers {
if len(provider.Models) == 0 {
continue
}
lines = append(lines, styles.subtitle.Render(provider.Provider))
if !provider.Available {
reason := string(provider.UnavailableReason)
if reason == "" {
reason = "unavailable"
}
lines = append(lines, " "+styles.dimmedText.Render(reason))
lines = append(lines, "")
continue
}
for _, model := range provider.Models {
hasModels = true
name := model.DisplayName
if strings.TrimSpace(name) == "" {
name = model.Model
}
marker := " "
if flatIndex == cursor {
marker = "> "
}
rowStyle := styles.normalItem
if model.ID == selected {
rowStyle = styles.selectedItem
}
lines = append(lines, marker+rowStyle.Render(styles.truncate(name, max(innerWidth-2, 0))))
if flatIndex == cursor {
cursorLine = len(lines) - 1
}
flatIndex++
}
lines = append(lines, "")
}
if !hasModels {
lines = append(lines, styles.dimmedText.Render("No models available."))
lines = append(lines, "")
}
help := styles.helpText.Render("Esc to close, Enter to select")
contentLines := lines
maxContentLines := max(height-countRenderedLines(help)-4, 1)
if height <= 0 {
maxContentLines = len(contentLines)
}
windowStart := 0
if cursorLine >= maxContentLines {
windowStart = cursorLine - maxContentLines + 1
}
maxWindowStart := max(len(contentLines)-maxContentLines, 0)
windowStart = min(windowStart, maxWindowStart)
windowEnd := min(windowStart+maxContentLines, len(contentLines))
content := append([]string(nil), contentLines[windowStart:windowEnd]...)
content = append(content, help)
return renderOverlayFrame(styles, width, strings.Join(content, "\n"))
}
func renderAskUserQuestion(styles tuiStyles, state *askUserQuestionState, width, height int) string {
if state == nil || len(state.Questions) == 0 {
return ""
}
if state.CurrentIndex < 0 || state.CurrentIndex >= len(state.Questions) {
return ""
}
innerWidth := contentWidth(width, 6)
question := state.Questions[state.CurrentIndex]
sections := []string{styles.title.Render(fmt.Sprintf("Plan Question %d/%d", state.CurrentIndex+1, len(state.Questions)))}
if question.Header != "" {
sections = append(sections, styles.subtitle.Render(sanitizeTerminalRenderableText(question.Header)))
}
sections = append(sections, wrapPreservingNewlines(sanitizeTerminalRenderableText(question.Question), innerWidth))
if state.Submitting {
sections = append(sections, styles.dimmedText.Render("Submitting answers..."))
return renderOverlayFrame(styles, width, sections...)
}
optionLines := make([]string, 0, len(question.Options)+3)
for i, option := range question.Options {
label := strings.TrimSpace(sanitizeTerminalRenderableText(option.Label))
if label == "" {
label = "(empty option)"
}
label = styles.truncate(label, max(innerWidth-2, 0))
row := " " + label
if i == state.OptionCursor {
row = styles.selectedItem.Render("> " + label)
}
optionLines = append(optionLines, row)
}
otherLabel := styles.truncate("Other (type custom answer)", max(innerWidth-2, 0))
otherRow := " " + otherLabel
if state.OptionCursor == len(question.Options) {
otherRow = styles.selectedItem.Render("> " + otherLabel)
}
optionLines = append(optionLines, otherRow)
if state.OtherMode {
optionLines = append(optionLines, "", state.OtherInput.View())
}
sections = append(sections, strings.Join(optionLines, "\n"))
if state.Error != nil {
sections = append(sections, styles.errorText.Render(wrapPreservingNewlines(
"Error: "+sanitizeTerminalRenderableText(state.Error.Error()),
innerWidth,
)))
}
longHelpParts := []string{"↑/↓ navigate", "enter select"}
shortHelpParts := []string{"↑↓", "↵"}
compactHelpParts := []string{"↑↓", "↵"}
if state.CurrentIndex > 0 {
longHelpParts = append(longHelpParts, "←/h back")
shortHelpParts = append(shortHelpParts, "←/h")
compactHelpParts = append(compactHelpParts, "←")
}
if state.OtherMode {
longHelpParts = append(longHelpParts, "esc cancel input")
shortHelpParts = append(shortHelpParts, "esc input")
compactHelpParts = append(compactHelpParts, "esc")
}
sections = append(sections, styles.helpText.Render(fitHelpText(
innerWidth,
strings.Join(longHelpParts, " | "),
strings.Join(shortHelpParts, " │ "),
strings.Join(compactHelpParts, " "),
)))
_ = height
return renderOverlayFrame(styles, width, sections...)
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderChatBlocks(styles tuiStyles, blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool, composerFocused bool, width int, renderers ...*glamour.TermRenderer) string {
if len(blocks) == 0 {
return ""
}
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
activeSelection := -1
if !composerFocused {
activeSelection = selectedBlock
}
visibleIndices := collapseConsecutiveSameNameBlocks(blocks, activeSelection, expandedBlocks)
rendered := make([]string, 0, len(visibleIndices))
for _, index := range visibleIndices {
blockView := blocks[index].cachedRender
if blockView == "" ||
blocks[index].cachedWidth != width ||
blocks[index].cachedExpanded != expandedBlocks[index] ||
blocks[index].cachedCollapsedCount != blocks[index].collapsedCount {
blockView = renderBlock(styles, blocks[index], expandedBlocks[index], width, renderer)
blocks[index].cachedRender = blockView
blocks[index].cachedWidth = width
blocks[index].cachedExpanded = expandedBlocks[index]
blocks[index].cachedCollapsedCount = blocks[index].collapsedCount
}
if index == activeSelection {
blockView = styles.selectedBlock.Render(blockView)
}
rendered = append(rendered, blockView)
}
return strings.Join(rendered, "\n")
}
//nolint:revive // Signature is dictated by the chat TUI view code.
func renderStatusBar(styles tuiStyles, chat *codersdk.Chat, status codersdk.ChatStatus, usage *codersdk.ChatMessageUsage, queueCount int, interrupting, reconnecting bool, width int) string {
_ = chat
parts := []string{styles.statusColor(status).Render(string(status))}
if usage != nil && usage.TotalTokens != nil && usage.ContextLimit != nil {
total := *usage.TotalTokens
limit := *usage.ContextLimit
if limit > 0 {
tokenText := fmt.Sprintf("tokens: %d/%d", total, limit)
pct := float64(total) / float64(limit) * 100
switch {
case pct > 95:
tokenText = styles.criticalText.Render(tokenText)
case pct > 80:
tokenText = styles.warningText.Render(tokenText)
}
parts = append(parts, tokenText)
}
}
if queueCount > 0 {
parts = append(parts, fmt.Sprintf("queued: %d", queueCount))
}
if interrupting {
parts = append(parts, styles.warningText.Render("interrupting…"))
}
if reconnecting {
parts = append(parts, styles.warningText.Render("reconnecting…"))
}
line := strings.Join(parts, styles.separator.Render(" │ "))
bar := styles.statusBar
if width > 0 {
bar = bar.MaxWidth(width)
}
return bar.Render(line)
}
func collapseConsecutiveSameNameBlocks(blocks []chatBlock, selectedBlock int, expandedBlocks map[int]bool) []int {
if len(blocks) == 0 {
return nil
}
for i := range blocks {
blocks[i].collapsedCount = 0
}
visibleIndices := make([]int, 0, len(blocks))
for i := 0; i < len(blocks); {
runEnd := i + 1
for runEnd < len(blocks) && canCollapseToolBlocks(blocks[i], blocks[runEnd]) {
runEnd++
}
if runEnd-i < 2 || hasExpandedToolBlock(expandedBlocks, i, runEnd) {
for j := i; j < runEnd; j++ {
visibleIndices = append(visibleIndices, j)
}
i = runEnd
continue
}
representative := i
if selectedBlock >= i && selectedBlock < runEnd {
representative = selectedBlock
}
blocks[representative].collapsedCount = runEnd - i
visibleIndices = append(visibleIndices, representative)
i = runEnd
}
return visibleIndices
}
func canCollapseToolBlocks(a, b chatBlock) bool {
if a.kind != b.kind {
return false
}
if a.kind != blockToolCall && a.kind != blockToolResult {
return false
}
if a.toolName != b.toolName {
return false
}
if a.kind == blockToolResult && a.isError != b.isError {
return false
}
if a.args != b.args || a.result != b.result {
return false
}
return true
}
func hasExpandedToolBlock(expandedBlocks map[int]bool, start, end int) bool {
for i := start; i < end; i++ {
if expandedBlocks[i] {
return true
}
}
return false
}
func messagesToBlocks(messages []codersdk.ChatMessage) []chatBlock {
blocks := make([]chatBlock, 0)
for _, message := range messages {
if message.Role == codersdk.ChatMessageRoleSystem {
continue
}
for _, part := range message.Content {
switch part.Type {
case codersdk.ChatMessagePartTypeText:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: part.Text})
case codersdk.ChatMessagePartTypeReasoning:
blocks = append(blocks, chatBlock{kind: blockReasoning, role: message.Role, text: part.Text})
case codersdk.ChatMessagePartTypeToolCall, codersdk.ChatMessagePartTypeToolResult:
block := chatBlock{role: message.Role, toolName: part.ToolName, toolID: part.ToolCallID}
switch {
case part.ToolName == contextCompactionToolName:
block.kind = blockCompaction
case part.Type == codersdk.ChatMessagePartTypeToolCall:
block.kind = blockToolCall
block.args = compactTranscriptJSON(part.Args)
default:
block.kind = blockToolResult
block.result = compactTranscriptJSON(part.Result)
block.isError = part.IsError
}
blocks = append(blocks, block)
case codersdk.ChatMessagePartTypeSource:
title := part.Title
if strings.TrimSpace(title) == "" {
title = part.URL
}
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[Source: %s](%s)", title, part.URL)})
case codersdk.ChatMessagePartTypeFile:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[File: %s]", part.MediaType)})
case codersdk.ChatMessagePartTypeFileReference:
blocks = append(blocks, chatBlock{kind: blockText, role: message.Role, text: fmt.Sprintf("[%s L%d-%d]", part.FileName, part.StartLine, part.EndLine)})
}
}
}
return mergeConsecutiveToolBlocks(blocks)
}
func mergeToolResult(call, result chatBlock) chatBlock {
if call.toolName != "" {
result.toolName = call.toolName
}
result.kind = blockToolResult
result.toolID = call.toolID
result.args = call.args
return result
}
func mergeConsecutiveToolBlocks(blocks []chatBlock) []chatBlock {
if len(blocks) < 2 {
return blocks
}
merged := make([]chatBlock, 0, len(blocks))
for i := 0; i < len(blocks); i++ {
block := blocks[i]
if i+1 < len(blocks) {
next := blocks[i+1]
if block.kind == blockToolCall && next.kind == blockToolResult {
switch {
case block.toolID != "" && block.toolID == next.toolID:
merged = append(merged, mergeToolResult(block, next))
i++
continue
case block.toolID == "" && next.toolID == "" && block.toolName == next.toolName:
merged = append(merged, mergeToolResult(block, next))
i++
continue
}
}
}
merged = append(merged, block)
}
return merged
}
//nolint:revive // Signature keeps block expansion state explicit at the callsite.
func renderBlock(styles tuiStyles, block chatBlock, expanded bool, width int, renderers ...*glamour.TermRenderer) string {
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
switch block.kind {
case blockText:
switch block.role {
case codersdk.ChatMessageRoleUser:
return renderPrefixedBlock(styles.userMessage.Render("You: "), block.text, width)
case codersdk.ChatMessageRoleAssistant:
return renderAssistantMarkdown(styles, block.text, width, renderer)
case codersdk.ChatMessageRoleTool:
return styles.dimmedText.Render(wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width))
default:
return wrapPreservingNewlines(sanitizeTerminalRenderableText(block.text), width)
}
case blockReasoning:
content := wrapPreservingNewlines(reasoningPrefix+sanitizeTerminalRenderableText(block.text), width)
if !expanded {
content = clampLines(content, 3)
}
return styles.reasoning.Render(content)
case blockToolCall:
if !expanded {
return renderToolCallBlock(styles, block, width)
}
return renderExpandedToolBlock(styles, styles.toolPending, pendingToolIcon, block.toolName, block.args, "", width)
case blockToolResult:
if !expanded {
return renderToolResultBlock(styles, block, width)
}
icon := "✓"
labelStyle := styles.toolSuccess
if block.isError {
icon = "✗"
labelStyle = styles.errorText
}
result := block.result
if strings.TrimSpace(result) == "" {
result = "null"
}
return renderExpandedToolBlock(styles, labelStyle, icon, block.toolName, block.args, result, width)
case blockCompaction:
return renderCompaction(styles, width)
default:
return ""
}
}
var (
fallbackMarkdownRenderers sync.Map
markdownRendererMu sync.Mutex
)
func getFallbackMarkdownRenderer(width int) *glamour.TermRenderer {
wrapWidth := contentWidth(width, 0)
if cachedRenderer, ok := fallbackMarkdownRenderers.Load(wrapWidth); ok {
renderer, ok := cachedRenderer.(*glamour.TermRenderer)
if ok {
return renderer
}
}
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
return nil
}
cachedRenderer, _ := fallbackMarkdownRenderers.LoadOrStore(wrapWidth, renderer)
storedRenderer, ok := cachedRenderer.(*glamour.TermRenderer)
if !ok {
return nil
}
return storedRenderer
}
func renderAssistantMarkdown(styles tuiStyles, text string, width int, renderers ...*glamour.TermRenderer) string {
text = sanitizeTerminalRenderableText(text)
var renderer *glamour.TermRenderer
if len(renderers) > 0 {
renderer = renderers[0]
}
if renderer == nil {
renderer = getFallbackMarkdownRenderer(width)
}
if renderer != nil {
markdownRendererMu.Lock()
rendered, err := renderer.Render(text)
markdownRendererMu.Unlock()
if err == nil {
trimmedRendered := strings.TrimRight(rendered, "\n")
if strings.TrimSpace(trimmedRendered) != "" || strings.TrimSpace(text) == "" {
return styles.assistantMsg.Render(trimmedRendered)
}
}
}
return styles.assistantMsg.Render(wrapPreservingNewlines(text, width))
}
func renderPrefixedBlock(prefix, body string, width int) string {
body = sanitizeTerminalRenderableText(body)
if strings.TrimSpace(body) == "" {
return prefix
}
prefixWidth := lipgloss.Width(prefix)
available := width - prefixWidth
if available <= 0 {
available = width
}
wrapped := wrapPreservingNewlines(body, available)
lines := strings.Split(wrapped, "\n")
if len(lines) == 0 {
return prefix
}
for i := 1; i < len(lines); i++ {
lines[i] = strings.Repeat(" ", max(prefixWidth, 0)) + lines[i]
}
return prefix + strings.Join(lines, "\n")
}
func wrapPreservingNewlines(text string, width int) string {
if width <= 0 {
return text
}
style := lipgloss.NewStyle().Width(width)
segments := strings.Split(text, "\n")
for i, segment := range segments {
segments[i] = strings.TrimRight(style.Render(segment), " ")
}
return strings.Join(segments, "\n")
}
func clampLines(text string, maxLines int) string {
return strings.Join(clampLineSlice(strings.Split(text, "\n"), maxLines), "\n")
}
func clampLineSlice(lines []string, maxLines int) []string {
if maxLines <= 0 {
return nil
}
if len(lines) <= maxLines {
return lines
}
clamped := append([]string(nil), lines[:maxLines]...)
clamped[maxLines-1] = stylesafeEllipsis(clamped[maxLines-1])
return clamped
}
func stylesafeEllipsis(line string) string {
trimmed := strings.TrimRight(line, " ")
if trimmed == "" {
return "…"
}
return trimmed + "…"
}
func countRenderedLines(text string) int {
if text == "" {
return 0
}
return strings.Count(text, "\n") + 1
}
+864
View File
@@ -0,0 +1,864 @@
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
}
+131
View File
@@ -0,0 +1,131 @@
package cli //nolint:testpackage // Tests unexported chat stream helpers.
import (
"bytes"
"fmt"
"io"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
type chatWatchWriters struct{ stdout, stderr io.Writer }
func (w chatWatchWriters) Write(p []byte) (int, error) { return w.stdout.Write(p) }
func (w chatWatchWriters) Stderr() io.Writer {
if w.stderr != nil {
return w.stderr
}
return w.stdout
}
func consumeChatStream(eventCh <-chan codersdk.ChatStreamEvent, out io.Writer) error {
errOut := out
if writer, ok := out.(interface{ Stderr() io.Writer }); ok {
errOut = writer.Stderr()
}
printedInline := false
flush := func() error {
if !printedInline {
return nil
}
printedInline = false
_, err := fmt.Fprintln(out)
return err
}
printLine := func(dst io.Writer, format string, args ...any) error {
if err := flush(); err != nil {
return err
}
_, err := fmt.Fprintf(dst, format, args...)
return err
}
for event := range eventCh {
var err error
switch event.Type {
case codersdk.ChatStreamEventTypeMessagePart:
if part := event.MessagePart; part != nil &&
part.Part.Type == codersdk.ChatMessagePartTypeText && part.Part.Text != "" {
printedInline = true
_, err = fmt.Fprint(out, part.Part.Text)
}
case codersdk.ChatStreamEventTypeMessage:
if message := event.Message; message != nil && !printedInline {
for _, part := range message.Content {
if part.Type != codersdk.ChatMessagePartTypeText || part.Text == "" {
continue
}
printedInline = true
if _, err = fmt.Fprint(out, part.Text); err != nil {
break
}
}
}
if err == nil {
err = flush()
}
case codersdk.ChatStreamEventTypeStatus:
if event.Status == nil {
err = flush()
break
}
err = printLine(out, "[Status: %s]\n", event.Status.Status)
case codersdk.ChatStreamEventTypeError:
if event.Error == nil {
err = flush()
break
}
err = printLine(errOut, "[Error: %s]\n", event.Error.Message)
case codersdk.ChatStreamEventTypeRetry:
if event.Retry == nil {
err = flush()
break
}
err = printLine(out, "[Retry attempt %d after error: %s]\n", event.Retry.Attempt, event.Retry.Error)
case codersdk.ChatStreamEventTypeQueueUpdate:
default:
err = printLine(out, "[Event: %s]\n", event.Type)
}
if err != nil {
return xerrors.Errorf("render chat stream event: %w", err)
}
}
if err := flush(); err != nil {
return xerrors.Errorf("flush chat stream output: %w", err)
}
return nil
}
func TestConsumeChatStreamText(t *testing.T) {
t.Parallel()
events := make(chan codersdk.ChatStreamEvent, 7)
for _, event := range []codersdk.ChatStreamEvent{
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: "Hello"}}},
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeToolCall, Text: "ignored"}}},
{Type: codersdk.ChatStreamEventTypeMessagePart, MessagePart: &codersdk.ChatStreamMessagePart{Role: codersdk.ChatMessageRoleAssistant, Part: codersdk.ChatMessagePart{Type: codersdk.ChatMessagePartTypeText, Text: " world"}}},
{Type: codersdk.ChatStreamEventTypeMessage, Message: &codersdk.ChatMessage{ID: 1, ChatID: uuid.New(), Role: codersdk.ChatMessageRoleAssistant, Content: []codersdk.ChatMessagePart{{Type: codersdk.ChatMessagePartTypeText, Text: "Hello world"}}}},
{Type: codersdk.ChatStreamEventTypeStatus, Status: &codersdk.ChatStreamStatus{Status: codersdk.ChatStatusRunning}},
{Type: codersdk.ChatStreamEventTypeRetry, Retry: &codersdk.ChatStreamRetry{Attempt: 2, Error: "rate limited"}},
{Type: codersdk.ChatStreamEventTypeError, Error: &codersdk.ChatStreamError{Message: "boom"}},
} {
events <- event
}
close(events)
var stdout bytes.Buffer
var stderr bytes.Buffer
err := consumeChatStream(events, chatWatchWriters{stdout: &stdout, stderr: &stderr})
require.NoError(t, err)
require.Equal(t, "Hello world\n[Status: running]\n[Retry attempt 2 after error: rate limited]\n", stdout.String())
require.Equal(t, "[Error: boom]\n", stderr.String())
}
+98
View File
@@ -0,0 +1,98 @@
package cli
import (
"github.com/charmbracelet/lipgloss"
"github.com/coder/coder/v2/codersdk"
)
type tuiStyles struct {
title lipgloss.Style
subtitle lipgloss.Style
statusBar lipgloss.Style
statusBadge lipgloss.Style
selectedItem lipgloss.Style
selectedBlock lipgloss.Style
normalItem lipgloss.Style
dimmedText lipgloss.Style
errorText lipgloss.Style
searchInput lipgloss.Style
separator lipgloss.Style
helpText lipgloss.Style
modeBadgeExec lipgloss.Style
modeBadgePlan lipgloss.Style
userMessage lipgloss.Style
assistantMsg lipgloss.Style
reasoning lipgloss.Style
toolCallStyle lipgloss.Style
toolPending lipgloss.Style
toolSuccess lipgloss.Style
compaction lipgloss.Style
warningText lipgloss.Style
criticalText lipgloss.Style
overlayBorder lipgloss.Style
composerStyle lipgloss.Style
}
func newTUIStyles(renderers ...*lipgloss.Renderer) tuiStyles {
renderer := lipgloss.DefaultRenderer()
if len(renderers) > 0 && renderers[0] != nil {
renderer = renderers[0]
}
return tuiStyles{
title: renderer.NewStyle().Bold(true),
subtitle: renderer.NewStyle().Faint(true),
statusBar: renderer.NewStyle(),
statusBadge: renderer.NewStyle().Padding(0, 1),
selectedItem: renderer.NewStyle().Bold(true),
selectedBlock: renderer.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.AdaptiveColor{Light: "63", Dark: "63"}).
PaddingLeft(1),
normalItem: renderer.NewStyle(),
dimmedText: renderer.NewStyle().Faint(true),
errorText: renderer.NewStyle().Foreground(lipgloss.Color("1")),
searchInput: renderer.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true),
separator: renderer.NewStyle().Faint(true),
helpText: renderer.NewStyle().Faint(true),
modeBadgeExec: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "42"}),
modeBadgePlan: renderer.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "214"}),
userMessage: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("6")),
assistantMsg: renderer.NewStyle(),
reasoning: renderer.NewStyle().Faint(true).Italic(true),
toolCallStyle: renderer.NewStyle().Foreground(lipgloss.Color("3")),
toolPending: renderer.NewStyle().Faint(true).Foreground(lipgloss.Color("3")),
toolSuccess: renderer.NewStyle().Foreground(lipgloss.Color("2")),
compaction: renderer.NewStyle().Bold(true).Foreground(lipgloss.Color("5")),
warningText: renderer.NewStyle().Foreground(lipgloss.Color("3")),
criticalText: renderer.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
overlayBorder: renderer.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(1),
composerStyle: renderer.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true),
}
}
func (s tuiStyles) statusColor(status codersdk.ChatStatus) lipgloss.Style {
color := lipgloss.Color("7")
switch status {
case codersdk.ChatStatusWaiting, codersdk.ChatStatusPending:
color = lipgloss.Color("3")
case codersdk.ChatStatusRunning:
color = lipgloss.Color("4")
case codersdk.ChatStatusPaused:
color = lipgloss.Color("5")
case codersdk.ChatStatusCompleted:
color = lipgloss.Color("2")
case codersdk.ChatStatusError:
color = lipgloss.Color("1")
}
return s.statusBadge.Foreground(color)
}
func (s tuiStyles) truncate(text string, maxWidth int) string {
_ = s
return truncateText(text, maxWidth, "", 3)
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -164,6 +164,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.promptExample(),
r.rptyCommand(),
r.syncCommand(),
r.agentsCommand(),
}
}