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

517 lines
14 KiB
Go

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