diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go new file mode 100644 index 000000000..00255e2e1 --- /dev/null +++ b/cmd/root/agent_picker.go @@ -0,0 +1,539 @@ +package root + +import ( + "context" + "errors" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/tui/components/scrollbar" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +// defaultAgentPickerRefs is the list of agent refs offered by the picker when +// the user doesn't pass --agent-picker with an explicit list. +var defaultAgentPickerRefs = []string{"default", "coder"} + +// errAgentPickerCancelled is returned when the user aborts the picker +// (Esc / Ctrl-C) without choosing an agent. +var errAgentPickerCancelled = errors.New("agent selection cancelled") + +// agentChoice is a single entry in the agent picker. +type agentChoice struct { + ref string // agent reference as passed on the command line + description string // one-line description loaded from the agent config + yaml string // raw config YAML, shown in the details dialog + err error // non-nil when the config could not be loaded +} + +// loadAgentChoices resolves and loads metadata for each ref so the picker can +// show a name and description. A ref that fails to load is still listed (with +// the error surfaced) so the user can see what went wrong instead of it +// silently disappearing. +func loadAgentChoices(ctx context.Context, refs []string, env environment.Provider) []agentChoice { + choices := make([]agentChoice, 0, len(refs)) + for _, ref := range refs { + choice := agentChoice{ref: ref} + + source, err := config.Resolve(ref, env) + if err != nil { + choice.err = err + choices = append(choices, choice) + continue + } + + if raw, err := source.Read(ctx); err == nil { + choice.yaml = string(raw) + } + + cfg, err := config.Load(ctx, source) + if err != nil { + choice.err = err + choices = append(choices, choice) + continue + } + + if len(cfg.Agents) > 0 { + root := cfg.Agents.First() + choice.description = root.Description + } + if cfg.Metadata.Description != "" { + choice.description = cfg.Metadata.Description + } + choices = append(choices, choice) + } + return choices +} + +// selectAgentRef shows a full-screen picker and returns the chosen agent ref. +// When only a single ref is supplied there is nothing to choose, so it is +// returned directly without showing any UI. +func selectAgentRef(ctx context.Context, refs []string, env environment.Provider) (string, error) { + if len(refs) == 0 { + return "", errors.New("no agent refs to choose from") + } + if len(refs) == 1 { + return refs[0], nil + } + + choices := loadAgentChoices(ctx, refs, env) + m := newAgentPickerModel(choices) + + p := tea.NewProgram(m, tea.WithContext(ctx)) + final, err := p.Run() + if err != nil { + return "", err + } + + result, ok := final.(*agentPickerModel) + if !ok || result.cancelled { + return "", errAgentPickerCancelled + } + return result.choices[result.cursor].ref, nil +} + +// agentPickerKeyMap holds the key bindings for the agent picker. +type agentPickerKeyMap struct { + Up key.Binding + Down key.Binding + Choose key.Binding + Details key.Binding + Quit key.Binding +} + +var agentPickerKeys = agentPickerKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Choose: key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter", "select"), + ), + Details: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "view yaml"), + ), + Quit: key.NewBinding( + key.WithKeys("esc", "ctrl+c", "q"), + key.WithHelp("esc", "cancel"), + ), +} + +// agentPickerModel is the bubbletea model backing the full-screen picker. +type agentPickerModel struct { + choices []agentChoice + cursor int + width int + height int + cancelled bool + + // showDetails toggles the scrollable YAML dialog overlay for the + // currently selected agent. + showDetails bool + details viewport.Model + detailsBar *scrollbar.Model +} + +func newAgentPickerModel(choices []agentChoice) *agentPickerModel { + vp := viewport.New() + vp.FillHeight = true + vp.SoftWrap = true + return &agentPickerModel{ + choices: choices, + details: vp, + detailsBar: scrollbar.New(), + } +} + +func (m *agentPickerModel) Init() tea.Cmd { return nil } + +func (m *agentPickerModel) moveUp() { + if m.cursor > 0 { + m.cursor-- + } +} + +func (m *agentPickerModel) moveDown() { + if m.cursor < len(m.choices)-1 { + m.cursor++ + } +} + +func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeDetails() + return m, nil + case tea.KeyPressMsg: + // While the YAML dialog is open it captures all keys: scrolling is + // delegated to the viewport, and any close key dismisses it. + if m.showDetails { + switch { + case key.Matches(msg, agentPickerKeys.Quit), key.Matches(msg, agentPickerKeys.Details): + m.showDetails = false + return m, nil + } + var cmd tea.Cmd + m.details, cmd = m.details.Update(msg) + m.syncDetailsBar() + return m, cmd + } + + switch { + case key.Matches(msg, agentPickerKeys.Quit): + m.cancelled = true + return m, tea.Quit + case key.Matches(msg, agentPickerKeys.Up): + m.moveUp() + return m, nil + case key.Matches(msg, agentPickerKeys.Down): + m.moveDown() + return m, nil + case key.Matches(msg, agentPickerKeys.Details): + m.openDetails() + return m, nil + case key.Matches(msg, agentPickerKeys.Choose): + return m, tea.Quit + } + } + return m, nil +} + +// Fixed YAML dialog dimensions. Keeping them constant means the dialog never +// moves or resizes while scrolling. They shrink only when the terminal is too +// small to hold the preferred size. +const ( + detailsDialogWidth = 90 + detailsDialogHeight = 28 + + // detailsChromeRows is the number of rows used by the dialog around the + // scrollable content: border (2) + padding (2) + title (1) + help (1). + detailsChromeRows = 6 + // detailsChromeCols is the number of columns used by the dialog around + // the content: border (2) + padding (4) + scrollbar (1). + detailsChromeCols = 2 + 4 + scrollbar.Width +) + +// detailsDialogSize returns the outer width and height of the YAML dialog, +// clamped so it always fits on screen with a small margin. +func (m *agentPickerModel) detailsDialogSize() (w, h int) { + w = min(detailsDialogWidth, max(m.width-4, 20)) + h = min(detailsDialogHeight, max(m.height-2, detailsChromeRows+1)) + return w, h +} + +// viewportSize returns the inner content dimensions of the YAML viewport. +func (m *agentPickerModel) viewportSize() (w, h int) { + dw, dh := m.detailsDialogSize() + return max(dw-detailsChromeCols, 1), max(dh-detailsChromeRows, 1) +} + +// resizeDetails keeps the viewport and its scrollbar sized to the current +// dialog dimensions. +func (m *agentPickerModel) resizeDetails() { + w, h := m.viewportSize() + m.details.SetWidth(w) + m.details.SetHeight(h) + m.syncDetailsBar() +} + +// syncDetailsBar mirrors the viewport's scroll state into the scrollbar. +func (m *agentPickerModel) syncDetailsBar() { + m.detailsBar.SetDimensions(m.details.Height(), m.details.TotalLineCount()) + m.detailsBar.SetScrollOffset(m.details.YOffset()) +} + +// openDetails loads the selected agent's YAML into the viewport and shows the +// dialog. +func (m *agentPickerModel) openDetails() { + if m.cursor < 0 || m.cursor >= len(m.choices) { + return + } + m.resizeDetails() + m.details.SetContent(m.detailsContent(m.choices[m.cursor])) + m.details.GotoTop() + m.syncDetailsBar() + m.showDetails = true +} + +// detailsContent returns the text shown in the YAML dialog for a choice. +func (m *agentPickerModel) detailsContent(choice agentChoice) string { + switch { + case choice.yaml != "": + return highlightYAML(strings.TrimRight(choice.yaml, "\n")) + case choice.err != nil: + return "Failed to load agent:\n\n" + sanitizeYAML(choice.err.Error()) + default: + return "No configuration available." + } +} + +// highlightYAML syntax-colorizes YAML using chroma with the active TUI theme. +// On any tokenisation error it returns the (sanitized) source unchanged. +func highlightYAML(src string) string { + src = sanitizeYAML(src) + lexer := lexers.Get("yaml") + if lexer == nil { + return src + } + iterator, err := chroma.Coalesce(lexer).Tokenise(nil, src) + if err != nil { + return src + } + + style := styles.ChromaStyle() + var b strings.Builder + for _, token := range iterator.Tokens() { + b.WriteString(chromaTokenStyle(token.Type, style).Render(token.Value)) + } + return b.String() +} + +// sanitizeYAML normalizes line endings, expands tabs, and strips terminal +// control characters from config content that may come from untrusted (remote) +// sources, so it cannot inject escape sequences or break the dialog layout. +func sanitizeYAML(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + s = strings.ReplaceAll(s, "\t", " ") + return stripControl(s) +} + +// stripControl removes control characters (including ESC) that could inject +// terminal escape sequences or corrupt the layout. Newlines are preserved. +func stripControl(s string) string { + return strings.Map(func(r rune) rune { + if r == '\n' { + return r + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) +} + +// chromaTokenStyle maps a chroma token type to a lipgloss style using the +// given chroma style (theme). +func chromaTokenStyle(tokenType chroma.TokenType, style *chroma.Style) lipgloss.Style { + entry := style.Get(tokenType) + s := lipgloss.NewStyle() + if entry.Colour.IsSet() { + s = s.Foreground(lipgloss.Color(entry.Colour.String())) + } + if entry.Bold == chroma.Yes { + s = s.Bold(true) + } + if entry.Italic == chroma.Yes { + s = s.Italic(true) + } + return s +} + +func (m *agentPickerModel) View() tea.View { + var body string + if m.showDetails { + body = m.renderDetails() + } else { + body = m.render() + } + centered := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, body) + + view := tea.NewView(centered) + view.AltScreen = true + view.BackgroundColor = styles.Background + view.WindowTitle = "Select an agent" + return view +} + +// agent picker card dimensions. +const ( + agentPickerCardWidth = 64 + agentPickerMinCardWidth = 24 +) + +// cardWidth returns the card width to use, shrinking to fit narrow terminals. +// The card is wrapped by the outer panel border (1) + padding (3) on each +// side, so it must leave room for that chrome. +func (m *agentPickerModel) cardWidth() int { + w := agentPickerCardWidth + if m.width > 0 { + if fit := m.width - 2*(1+3); fit < w { + w = fit + } + } + if w < agentPickerMinCardWidth { + w = agentPickerMinCardWidth + } + return w +} + +func (m *agentPickerModel) render() string { + title := styles.HighlightWhiteStyle.Render("Choose an agent to run") + subtitle := styles.MutedStyle.Render("Pick the agent you want to start a session with.") + + cards := make([]string, 0, len(m.choices)) + cardWidth := m.cardWidth() + for i, choice := range m.choices { + cards = append(cards, m.renderCard(choice, cardWidth, i == m.cursor)) + } + list := lipgloss.JoinVertical(lipgloss.Left, cards...) + + help := styles.MutedStyle.Render( + strings.Join([]string{ + "↑↓ move", + agentPickerKeys.Choose.Help().Key + " " + agentPickerKeys.Choose.Help().Desc, + agentPickerKeys.Details.Help().Key + " " + agentPickerKeys.Details.Help().Desc, + agentPickerKeys.Quit.Help().Key + " " + agentPickerKeys.Quit.Help().Desc, + }, " "), + ) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + subtitle, + "", + list, + "", + help, + ) + + return styles.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(styles.BorderSecondary). + Padding(1, 3). + Render(content) +} + +// renderDetails renders the scrollable YAML dialog for the selected agent. +func (m *agentPickerModel) renderDetails() string { + dw, _ := m.detailsDialogSize() + contentWidth := dw - detailsChromeCols + scrollbar.Width + + ref := m.choices[m.cursor].ref + title := styles.DialogTitleStyle.Width(contentWidth).Render(toolcommon.TruncateText(ref, contentWidth)) + + // Place the scrollbar immediately to the right of the viewport content. + // Reserve the column even when the content fits (empty scrollbar view) so + // the dialog width stays fixed. + _, vh := m.viewportSize() + bar := m.detailsBar.View() + if bar == "" { + bar = strings.TrimRight(strings.Repeat(" \n", vh), "\n") + } + body := lipgloss.JoinHorizontal( + lipgloss.Top, + m.details.View(), + bar, + ) + + help := styles.DialogHelpStyle. + Width(contentWidth). + Render("↑↓ scroll • " + percentLabel(m.details.ScrollPercent()) + " esc/? close") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + body, + help, + ) + + return styles.DialogStyle.Render(content) +} + +// percentLabel formats a scroll fraction (0..1) as a percentage string. +func percentLabel(frac float64) string { + pct := min(max(int(frac*100), 0), 100) + return strconv.Itoa(pct) + "%" +} + +func (m *agentPickerModel) renderCard(choice agentChoice, cardWidth int, selected bool) string { + marker := " " + nameStyle := styles.BoldStyle + borderColor := styles.BorderMuted + if selected { + marker = styles.SuccessStyle.Render("❯ ") + nameStyle = styles.HighlightWhiteStyle + borderColor = styles.BorderPrimary + } + + // The marker occupies 2 columns and the card chrome (border + padding) + // 4, so the ref text gets cardWidth-6. + header := marker + nameStyle.Render(toolcommon.TruncateText(choice.ref, cardWidth-6)) + + // Descriptions and load errors can come from arbitrary (including + // remote) configs, so collapse them to a single line and truncate to + // the card width to keep the layout intact. The detail sits inside the + // card's 2-space indent and 1-column horizontal padding on each side. + detailWidth := cardWidth - 4 + var detail string + switch { + case choice.err != nil: + detail = styles.ErrorStyle.Render(truncateDetail("failed to load: "+choice.err.Error(), detailWidth)) + case choice.description != "": + detail = styles.SecondaryStyle.Render(truncateDetail(choice.description, detailWidth)) + default: + detail = styles.MutedStyle.Render("No description available") + } + + card := lipgloss.JoinVertical(lipgloss.Left, header, " "+detail) + + return styles.BaseStyle. + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(cardWidth). + Padding(0, 1). + Render(card) +} + +// truncateDetail collapses whitespace (including newlines) into single spaces, +// strips terminal control characters, and truncates the result to width +// columns. This keeps card-detail text on a single line so untrusted or +// multi-line descriptions can't break the layout or inject escape sequences. +func truncateDetail(text string, width int) string { + return toolcommon.TruncateText(stripControl(strings.Join(strings.Fields(text), " ")), width) +} + +// prependAgentRef returns args with ref inserted as the leading positional +// argument. After an --agent-picker selection the remaining positional args +// are user messages, and the rest of the run pipeline expects args[0] to be +// the agent ref. +func prependAgentRef(ref string, args []string) []string { + return append([]string{ref}, args...) +} + +// parseAgentPickerRefs splits a comma-separated list of agent refs, trims +// whitespace, and drops empty entries. An empty or all-whitespace input +// yields the built-in defaults. +func parseAgentPickerRefs(raw string) []string { + if strings.TrimSpace(raw) == "" { + return defaultAgentPickerRefs + } + var refs []string + for part := range strings.SplitSeq(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + refs = append(refs, trimmed) + } + } + if len(refs) == 0 { + return defaultAgentPickerRefs + } + return refs +} diff --git a/cmd/root/agent_picker_test.go b/cmd/root/agent_picker_test.go new file mode 100644 index 000000000..20259fbc7 --- /dev/null +++ b/cmd/root/agent_picker_test.go @@ -0,0 +1,194 @@ +package root + +import ( + "errors" + "strconv" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" +) + +func TestParseAgentPickerRefs(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + {"empty defaults", "", []string{"default", "coder"}}, + {"whitespace defaults", " ", []string{"default", "coder"}}, + {"single ref", "coder", []string{"coder"}}, + {"multiple refs", "default,coder", []string{"default", "coder"}}, + {"trims whitespace", " default , coder ", []string{"default", "coder"}}, + {"drops empty entries", "default,,coder,", []string{"default", "coder"}}, + {"only commas defaults", ",,,", []string{"default", "coder"}}, + {"external refs", "default,agentcatalog/pirate", []string{"default", "agentcatalog/pirate"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, parseAgentPickerRefs(tt.raw)) + }) + } +} + +func TestPrependAgentRef(t *testing.T) { + assert.Equal(t, []string{"coder"}, prependAgentRef("coder", nil)) + assert.Equal(t, []string{"coder", "hello"}, prependAgentRef("coder", []string{"hello"})) + assert.Equal(t, []string{"coder", "a", "b"}, prependAgentRef("coder", []string{"a", "b"})) +} + +func TestTruncateDetail(t *testing.T) { + // Collapses newlines and runs of whitespace into single spaces. + assert.Equal(t, "a b c", truncateDetail("a\nb\t c", 80)) + // Truncates to width with an ellipsis. + assert.Equal(t, "hel…", truncateDetail("hello world", 4)) + // Empty / whitespace-only input collapses to empty. + assert.Empty(t, truncateDetail(" \n\t ", 80)) +} + +func TestAgentPickerRenderNoPanic(t *testing.T) { + choices := []agentChoice{ + {ref: "default", description: "A helpful AI assistant", yaml: "agents:\n root:\n model: auto\n"}, + {ref: "agentcatalog/some-really-long-agent-reference-name", description: strings.Repeat("very long description ", 20)}, + {ref: "broken", err: errors.New("multi\nline\nerror that is also quite long and should be truncated cleanly")}, + } + m := newAgentPickerModel(choices) + + // Render across a range of widths, including degenerate ones, to make + // sure width math never produces a panic or a negative truncation width. + for _, w := range []int{0, 1, 10, 30, 80, 200} { + m.width = w + m.height = 24 + assert.NotPanics(t, func() { _ = m.render() }) + m.openDetails() + assert.NotPanics(t, func() { _ = m.renderDetails() }) + m.showDetails = false + } +} + +func TestAgentPickerDetailsToggle(t *testing.T) { + m := newAgentPickerModel([]agentChoice{ + {ref: "default", yaml: "agents:\n root:\n model: auto\n"}, + }) + m.width = 80 + m.height = 24 + + assert.False(t, m.showDetails) + m.openDetails() + assert.True(t, m.showDetails) + assert.Contains(t, ansi.Strip(m.details.GetContent()), "model: auto") +} + +func TestDetailsContent(t *testing.T) { + m := newAgentPickerModel(nil) + // YAML is syntax-highlighted, so compare with ANSI stripped. + assert.Equal(t, "a: b", ansi.Strip(m.detailsContent(agentChoice{yaml: "a: b\n\n"}))) + assert.Contains(t, m.detailsContent(agentChoice{err: errors.New("boom")}), "boom") + assert.Equal(t, "No configuration available.", m.detailsContent(agentChoice{})) +} + +func TestHighlightYAML(t *testing.T) { + src := "agents:\n root:\n model: auto" + out := highlightYAML(src) + // Colorized output differs from the input but preserves the text + // (ignoring any insignificant trailing whitespace per line). + assert.NotEqual(t, src, out) + assert.Equal(t, src, trimTrailingPerLine(ansi.Strip(out))) +} + +func trimTrailingPerLine(s string) string { + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " ") + } + return strings.Join(lines, "\n") +} + +func TestPercentLabel(t *testing.T) { + assert.Equal(t, "0%", percentLabel(0)) + assert.Equal(t, "50%", percentLabel(0.5)) + assert.Equal(t, "100%", percentLabel(1)) + assert.Equal(t, "0%", percentLabel(-0.5)) + assert.Equal(t, "100%", percentLabel(2)) +} + +func TestAgentPickerDetailsFixedSize(t *testing.T) { + // A long YAML so the viewport is scrollable. + var sb strings.Builder + for i := range 200 { + sb.WriteString("line " + strconv.Itoa(i) + "\n") + } + m := newAgentPickerModel([]agentChoice{{ref: "default", yaml: sb.String()}}) + m.width = 120 + m.height = 40 + m.openDetails() + + top := m.renderDetails() + topW, topH := lipgloss.Size(top) + + // Scroll down a few lines and to the bottom; dimensions must not change. + for range 5 { + m.details.ScrollDown(1) + m.syncDetailsBar() + w, h := lipgloss.Size(m.renderDetails()) + assert.Equal(t, topW, w, "width changed while scrolling") + assert.Equal(t, topH, h, "height changed while scrolling") + } + + m.details.GotoBottom() + m.syncDetailsBar() + w, h := lipgloss.Size(m.renderDetails()) + assert.Equal(t, topW, w, "width changed at bottom") + assert.Equal(t, topH, h, "height changed at bottom") +} + +func TestStripControl(t *testing.T) { + // The ESC byte is removed, neutralizing the escape sequence (the + // remaining "[31m" is harmless literal text). Other control chars go too; + // newlines are preserved. + assert.Equal(t, "[31mredtext[0m", stripControl("\x1b[31mredtext\x1b[0m")) + assert.NotContains(t, stripControl("\x1b[31mredtext\x1b[0m"), "\x1b") + assert.Equal(t, "ab", stripControl("a\x07b")) + assert.Equal(t, "line1\nline2", stripControl("line1\nline2")) + assert.Equal(t, "ab", stripControl("a\x7fb")) +} + +func TestSanitizeYAML(t *testing.T) { + // CRLF/CR normalized to LF, tabs expanded, ESC/control chars stripped. + assert.Equal(t, "a\nb", sanitizeYAML("a\r\nb")) + assert.Equal(t, "a\nb", sanitizeYAML("a\rb")) + assert.Equal(t, " x", sanitizeYAML("\tx")) + assert.NotContains(t, sanitizeYAML("key: \x1b[31mvalue\x1b[0m"), "\x1b") +} + +func TestHighlightYAMLStripsInjectedEscapes(t *testing.T) { + // A malicious config can't smuggle its own escape sequences through. + out := highlightYAML("key: \x1b[31mvalue\x1b[0m\x07") + plain := ansi.Strip(out) + assert.NotContains(t, plain, "\x1b") + assert.NotContains(t, plain, "\x07") + assert.Contains(t, plain, "value") +} + +func TestAgentPickerModelNavigation(t *testing.T) { + m := newAgentPickerModel([]agentChoice{ + {ref: "default"}, + {ref: "coder"}, + }) + + // Up at the top is a no-op. + m.moveUp() + assert.Equal(t, 0, m.cursor) + + m.moveDown() + assert.Equal(t, 1, m.cursor) + + // Down at the bottom is a no-op. + m.moveDown() + assert.Equal(t, 1, m.cursor) + + m.moveUp() + assert.Equal(t, 0, m.cursor) +} diff --git a/cmd/root/run.go b/cmd/root/run.go index 6a97f9aef..9d6f8fe64 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -56,6 +56,7 @@ type runExecFlags struct { sandboxTemplate string sbx bool noKit bool + agentPickerSpec string // Exec only exec bool @@ -152,6 +153,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.PersistentFlags().BoolVar(&flags.sbx, "sbx", true, "Prefer the sbx CLI backend when available (set --sbx=false to force docker sandbox)") cmd.PersistentFlags().BoolVar(&flags.noKit, "no-kit", false, "Do not stage a docker-agent kit (skills, prompt files) when running in a sandbox") + cmd.PersistentFlags().StringVar(&flags.agentPickerSpec, "agent-picker", "", "Show a full-screen picker to choose an agent before launching. Optional comma-separated list of agent refs (defaults to \"default,coder\")") + cmd.PersistentFlags().Lookup("agent-picker").NoOptDefVal = strings.Join(defaultAgentPickerRefs, ",") cmd.MarkFlagsMutuallyExclusive("fake", "record") cmd.MarkFlagsMutuallyExclusive("remote", "sandbox") cmd.MarkFlagsMutuallyExclusive("remote", "session-db") @@ -189,6 +192,33 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command } } + useTUI := !f.exec && (f.forceTUI || isatty.IsTerminal(os.Stdout.Fd())) + + // When --agent-picker is set, show a full-screen picker up front and use + // the chosen ref as the agent to run. Resolving it here (before sandbox + // and alias resolution) means the selected agent's own sandbox/alias + // defaults are honoured exactly as if it had been passed positionally. + // The picker is interactive, so it requires a TUI. + if cmd.Flags().Changed("agent-picker") { + if !useTUI { + return errors.New("--agent-picker requires an interactive terminal and cannot be used with --exec") + } + refs := parseAgentPickerRefs(f.agentPickerSpec) + applyTheme(f.theme) + chosen, err := selectAgentRef(ctx, refs, f.runConfig.EnvProvider()) + if err != nil { + if errors.Is(err, errAgentPickerCancelled) { + cli.NewPrinter(cmd.OutOrStdout()).Println("Agent selection cancelled.") + return nil + } + return err + } + // With --agent-picker the agent comes from the picker, so any + // positional args are messages. Prepend the chosen ref so the rest + // of the pipeline (which expects args[0] to be the agent) is happy. + args = prependAgentRef(chosen, args) + } + // Resolve alias / runtime-declared sandbox opt-in before dispatch. // An explicit --sandbox= on the CLI always wins, so we only // consult the lower-priority sources when the flag wasn't set. @@ -207,7 +237,6 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command out := cli.NewPrinter(cmd.OutOrStdout()) - useTUI := !f.exec && (f.forceTUI || isatty.IsTerminal(os.Stdout.Fd())) return f.runOrExec(ctx, out, args, useTUI) }