Files
coder/cli/exp_agents_cmds.go
T
Michael Suchacz de30488b20 feat(cli): add experimental agents TUI (#24150)
> This PR was authored by Mux on behalf of Mike.

Adds `coder exp agents`, an interactive terminal UI for managing Coder
AI agent chats. Built with bubbletea/lipgloss/glamour, the TUI provides
parity with the web dashboard for chat management, model selection, and
real-time tool execution visibility.

## What it does

- **Chat list view**: tree-based navigation with nested subagent
expansion, search filtering, windowed scrolling, and pagination.
- **Active chat view**: viewport-based transcript with markdown
rendering, WebSocket streaming, and a text input composer for sending
messages.
- **Model picker overlay**: cached model catalog with fuzzy selection.
- **Diff drawer overlay**: git changes inspection with unified diff
rendering.
- **Tool call rendering**: humanized argument summaries, consecutive
duplicate collapsing, and status indicators.

## Key implementation details

- Session lifecycle uses a monotonic `chatGeneration` counter so async
responses from stale sessions are dropped on chat switch.
- Draft mode guards prevent duplicate chat creation on double-Enter.
- Error and loading states render inline without collapsing the TUI
chrome.
- Glamour renderer access is mutex-protected (not thread-safe).
- Intentional WebSocket close is distinguished from dropped connections
to prevent spurious reconnects.

## Testing

~220 unit tests covering rendering, state transitions, keyboard
dispatch, and edge cases. 4-scenario PTY-based E2E suite covers boot,
navigation, search, and direct chat open.

14 new files, ~7,400 lines added.
2026-04-17 12:16:06 +02:00

187 lines
4.5 KiB
Go

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}
}
}