mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
e57525002c
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
206 lines
4.9 KiB
Go
206 lines
4.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"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.",
|
|
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 := client.ResolveWorkspace(inv.Context(), 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 := newChatsTUIModel(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.(chatsTUIModel); !ok {
|
|
return xerrors.Errorf("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)
|
|
}
|