Files
coder/cli/agents_model.go
T
Dean Sheather e57525002c chore: remove agents experiment flag and mark feature as beta (#24432)
Remove the `ExperimentAgents` feature flag so the Agents feature is
always available without requiring `--experiments=agents`. The feature
is now in beta.

Existing deployments that still pass `--experiments=agents` will get a
harmless "ignoring unknown experiment" warning on startup.

### Changes

**Backend:**
- Remove `RequireExperimentWithDevBypass` middleware from chat and MCP
server routes
- Always include `AgentsAccessRole` in assignable site roles (later
refactored to org-scoped on main; rebase keeps that)
- Always set `AgentsTabVisible = true`, then drop the entire dead
`AgentsTabVisible` metadata pipeline (Go htmlState field,
populateHTMLState goroutine, HTML meta tag, useEmbeddedMetadata
registration, mock); no production consumer reads it. `AgentsNavItem`
already gates on `permissions.createChat`.
- Make `blob:` CSP `img-src` addition unconditional
- Remove `ExperimentAgents` constant, `DisplayName` case, and
`ExperimentsKnown` entry

**CLI:**
- Graduate the agents TUI from `coder exp agents` to `coder agents`
(moved from `AGPLExperimental()` to `CoreSubcommands()`)
- Drop the `agent` alias so it does not collide with the hidden
workspace-agent command
- Rename implementation files `cli/exp_agents_*.go` -> `cli/agents_*.go`
and internal identifiers (`expChatsTUIModel` -> `chatsTUIModel`,
`newExpChatsTUIModel` -> `newChatsTUIModel`, `setupExpAgentsBackend` ->
`setupAgentsBackend`, `startExpAgentsSession` -> `startAgentsSession`,
`expAgentsPtr` -> `agentsPtr`, `expAgentsSession` -> `agentsSession`,
`TestExpAgents*` -> `TestAgents*`). `expClient` (the
`*codersdk.ExperimentalClient` local) is kept; `coderd/exp_chats*.go`
and other still-experimental `cli/exp_*.go` commands are intentionally
untouched.

**Frontend:**
- Remove experiment check from `AgentsNavItem` - render when
`canCreateChat` is true
- Remove `agentsEnabled` experiment check from `WorkspacesPage`, then
gate `chatsByWorkspace` on `permissions.createChat` so users without
chat access don't trigger the per-page DB query (Copilot review
feedback)
- Add `FeatureStageBadge` (beta) next to the Coder logo in the Agents
sidebar (desktop + mobile)

**Docs:**
- Remove experiment flag setup instructions from `early-access.md` and
`getting-started.md` (and rename `early-access.md`'s "Enable Coder
Agents" heading to "Set up Coder Agents", since there is no enablement
step left)
- Update `chats-api.md` and `getting-started.md`'s Chats API note to say
"beta" instead of "experimental"
- `docs/manifest.json`: drop "experimental" from the Chats API sidebar
description
- `make gen` regenerated `docs/reference/cli/agents.md` and the CLI
index
- `scripts/check_emdash.sh`: exclude `cli/testdata/*.golden` and
`enterprise/cli/testdata/*.golden` from the new repo-wide emdash lint,
since serpent emits emdash borders in every generated `--help` golden
file

**Tests:**
- Remove `ExperimentAgents` setup from all test files (14 occurrences
across 7 files)
- Update stale "with the agents experiment" comments in
`coderd/x/chatd/integration_test.go` and `coderd/mcp_test.go`


<img width="1185" height="900" alt="image"
src="https://github.com/user-attachments/assets/b420bc8f-41d6-42c6-abd8-ad572533d651"
/>


> 🤖 Generated by Coder Agents
2026-05-01 01:49:00 +10:00

515 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{}
chatsTUIModel 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 newChatsTUIModel(
ctx context.Context,
client *codersdk.ExperimentalClient,
initialChatID *uuid.UUID,
workspaceID *uuid.UUID,
modelOverride *string,
organizationID uuid.UUID,
) chatsTUIModel {
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 chatsTUIModel{
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 *chatsTUIModel) 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 *chatsTUIModel) 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 chatsTUIModel) 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 chatsTUIModel) 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 chatsTUIModel) 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 chatsTUIModel) 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 *chatsTUIModel) toggleOverlay(overlay tuiOverlay) bool {
if m.overlay == overlay {
m.overlay = overlayNone
return false
}
m.overlay = overlay
return true
}
func (m *chatsTUIModel) 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 *chatsTUIModel) 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 *chatsTUIModel) 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 *chatsTUIModel) 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 *chatsTUIModel) 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 *chatsTUIModel) 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 *chatsTUIModel) toggleDiffDrawerCmd() tea.Cmd {
if m.chat.chat == nil {
return nil
}
if !m.toggleOverlay(overlayDiffDrawer) {
return nil
}
if m.chat.diffContents == nil || m.chat.diffErr != nil {
m.chat.diffErr = nil
chatID := m.chat.chat.ID
generation := m.chat.chatGeneration
return apiCmd(func() (codersdk.ChatDiffContents, error) { return fetchChatDiffContents(m.ctx, m.client, chatID) }, func(diff codersdk.ChatDiffContents, err error) tea.Msg {
return diffContentsMsg{generation: generation, chatID: chatID, diff: diff, err: err}
})
}
return nil
}
func (m chatsTUIModel) updateChild(msg tea.Msg, view tuiView) (chatsTUIModel, 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 chatsTUIModel) 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 chatsTUIModel) 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.diffSummary, m.chat.diffStyledBody, 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 chatsTUIModel) 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, 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 chatsTUIModel) 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)
}