From 1b8a1ff2c767b680b4351f7effd236459d31df85 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 30 May 2026 13:25:14 +0200 Subject: [PATCH 1/5] feat: add --agent-picker flag for full-screen agent selection Add a --agent-picker flag to "docker agent run" that opens a full-screen pre-TUI to choose an agent from a comma-separated list of agent refs. When the list is omitted, it defaults to the built-in "default,coder" agents. The picker is hardened against untrusted (possibly remote) configs: descriptions and load errors are collapsed to a single line and truncated, the layout is responsive to narrow terminals, and the final-model type assertion is defensive against cancellation. --- cmd/root/agent_picker.go | 313 ++++++++++++++++++++++++++++++++++ cmd/root/agent_picker_test.go | 84 +++++++++ cmd/root/run.go | 31 +++- 3 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 cmd/root/agent_picker.go create mode 100644 cmd/root/agent_picker_test.go diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go new file mode 100644 index 000000000..401bc7c77 --- /dev/null +++ b/cmd/root/agent_picker.go @@ -0,0 +1,313 @@ +package root + +import ( + "context" + "errors" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/environment" + "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 + 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 + } + + 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 + 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"), + ), + 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 +} + +func newAgentPickerModel(choices []agentChoice) *agentPickerModel { + return &agentPickerModel{choices: choices} +} + +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 + return m, nil + case tea.KeyPressMsg: + 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.Choose): + return m, tea.Quit + } + } + return m, nil +} + +func (m *agentPickerModel) View() tea.View { + 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{ + agentPickerKeys.Up.Help().Key + " " + agentPickerKeys.Up.Help().Desc, + agentPickerKeys.Choose.Help().Key + " " + agentPickerKeys.Choose.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) +} + +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 +// 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. +func truncateDetail(text string, width int) string { + return toolcommon.TruncateText(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..ac4cbe45b --- /dev/null +++ b/cmd/root/agent_picker_test.go @@ -0,0 +1,84 @@ +package root + +import ( + "errors" + "strings" + "testing" + + "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"}, + {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() }) + } +} + +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) } From a34d097d270bb5cbe4c3f557ea88fc196fc41805 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 30 May 2026 13:38:41 +0200 Subject: [PATCH 2/5] feat: add yaml details dialog and fix help bar in agent picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix help bar to show '↑↓ move' instead of only listing up - Add '?' shortcut that opens scrollable dialog showing selected agent's raw config YAML - Dialog uses bubbles/viewport, dismissible with esc or ? - YAML is captured when loading each agent choice - Added tests for details toggle, content rendering, and scroll percentage --- cmd/root/agent_picker.go | 137 ++++++++++++++++++++++++++++++++-- cmd/root/agent_picker_test.go | 33 +++++++- 2 files changed, 162 insertions(+), 8 deletions(-) diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go index 401bc7c77..9fe8cbc5c 100644 --- a/cmd/root/agent_picker.go +++ b/cmd/root/agent_picker.go @@ -3,9 +3,11 @@ 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" @@ -27,6 +29,7 @@ var errAgentPickerCancelled = errors.New("agent selection cancelled") 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 } @@ -46,6 +49,10 @@ func loadAgentChoices(ctx context.Context, refs []string, env environment.Provid 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 @@ -94,10 +101,11 @@ func selectAgentRef(ctx context.Context, refs []string, env environment.Provider // agentPickerKeyMap holds the key bindings for the agent picker. type agentPickerKeyMap struct { - Up key.Binding - Down key.Binding - Choose key.Binding - Quit key.Binding + Up key.Binding + Down key.Binding + Choose key.Binding + Details key.Binding + Quit key.Binding } var agentPickerKeys = agentPickerKeyMap{ @@ -113,6 +121,10 @@ var agentPickerKeys = agentPickerKeyMap{ 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"), @@ -126,10 +138,18 @@ type agentPickerModel struct { width int height int cancelled bool + + // showDetails toggles the scrollable YAML dialog overlay for the + // currently selected agent. + showDetails bool + details viewport.Model } func newAgentPickerModel(choices []agentChoice) *agentPickerModel { - return &agentPickerModel{choices: choices} + return &agentPickerModel{ + choices: choices, + details: viewport.New(), + } } func (m *agentPickerModel) Init() tea.Cmd { return nil } @@ -151,8 +171,22 @@ func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) + return m, cmd + } + switch { case key.Matches(msg, agentPickerKeys.Quit): m.cancelled = true @@ -163,6 +197,9 @@ func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } @@ -170,8 +207,63 @@ func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// detailsDialogSize returns the width and height of the YAML dialog, leaving a +// margin around the screen so it reads as an overlay. +func (m *agentPickerModel) detailsDialogSize() (w, h int) { + w = m.width - 8 + h = m.height - 6 + if w > 100 { + w = 100 + } + if w < 20 { + w = 20 + } + if h < 5 { + h = 5 + } + return w, h +} + +// resizeDetails keeps the viewport sized to the current dialog dimensions. +// The viewport content area sits inside the dialog border (1) and padding (2) +// on each side, plus a title and help line (3 rows). +func (m *agentPickerModel) resizeDetails() { + w, h := m.detailsDialogSize() + m.details.SetWidth(w - 2*(1+2)) + m.details.SetHeight(h - 2*1 - 3) +} + +// 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.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 strings.TrimRight(choice.yaml, "\n") + case choice.err != nil: + return "Failed to load agent:\n\n" + choice.err.Error() + default: + return "No configuration available." + } +} + func (m *agentPickerModel) View() tea.View { - body := m.render() + 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) @@ -216,8 +308,9 @@ func (m *agentPickerModel) render() string { help := styles.MutedStyle.Render( strings.Join([]string{ - agentPickerKeys.Up.Help().Key + " " + agentPickerKeys.Up.Help().Desc, + "↑↓ 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, }, " "), ) @@ -239,6 +332,36 @@ func (m *agentPickerModel) render() string { Render(content) } +// renderDetails renders the scrollable YAML dialog for the selected agent. +func (m *agentPickerModel) renderDetails() string { + w, _ := m.detailsDialogSize() + contentWidth := w - 2*(1+2) + + ref := m.choices[m.cursor].ref + title := styles.DialogTitleStyle.Width(contentWidth).Render(toolcommon.TruncateText(ref, contentWidth)) + + scrollHint := "" + if !m.details.AtTop() || !m.details.AtBottom() { + scrollHint = " • " + percentLabel(m.details.ScrollPercent()) + } + help := styles.DialogHelpStyle.Render("↑↓ scroll" + scrollHint + " esc/? close") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + m.details.View(), + help, + ) + + return styles.DialogStyle.Width(w - 2).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 diff --git a/cmd/root/agent_picker_test.go b/cmd/root/agent_picker_test.go index ac4cbe45b..6c48451ac 100644 --- a/cmd/root/agent_picker_test.go +++ b/cmd/root/agent_picker_test.go @@ -47,7 +47,7 @@ func TestTruncateDetail(t *testing.T) { func TestAgentPickerRenderNoPanic(t *testing.T) { choices := []agentChoice{ - {ref: "default", description: "A helpful AI assistant"}, + {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")}, } @@ -59,9 +59,40 @@ func TestAgentPickerRenderNoPanic(t *testing.T) { 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, m.details.GetContent(), "model: auto") +} + +func TestDetailsContent(t *testing.T) { + m := newAgentPickerModel(nil) + assert.Equal(t, "a: b", 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 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 TestAgentPickerModelNavigation(t *testing.T) { m := newAgentPickerModel([]agentChoice{ {ref: "default"}, From 22e5540839a99d61b0c3a5986ea6941bb571c9ac Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 30 May 2026 13:47:20 +0200 Subject: [PATCH 3/5] feat: agent picker dialog now has fixed size with scrollbar The YAML details dialog now maintains a fixed 90x28 size (clamped to fit small terminals), so it no longer moves or resizes while scrolling. Added a vertical scrollbar using pkg/tui/components/scrollbar, kept in sync with viewport scroll state. The viewport uses FillHeight for better layout control. Added test asserting dimensions stay constant while scrolling. --- cmd/root/agent_picker.go | 95 ++++++++++++++++++++++++----------- cmd/root/agent_picker_test.go | 32 ++++++++++++ 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go index 9fe8cbc5c..54f75300d 100644 --- a/cmd/root/agent_picker.go +++ b/cmd/root/agent_picker.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -143,12 +144,16 @@ type agentPickerModel struct { // currently selected agent. showDetails bool details viewport.Model + detailsBar *scrollbar.Model } func newAgentPickerModel(choices []agentChoice) *agentPickerModel { + vp := viewport.New() + vp.FillHeight = true return &agentPickerModel{ - choices: choices, - details: viewport.New(), + choices: choices, + details: vp, + detailsBar: scrollbar.New(), } } @@ -184,6 +189,7 @@ func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd m.details, cmd = m.details.Update(msg) + m.syncDetailsBar() return m, cmd } @@ -207,30 +213,48 @@ func (m *agentPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// detailsDialogSize returns the width and height of the YAML dialog, leaving a -// margin around the screen so it reads as an overlay. +// 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 = m.width - 8 - h = m.height - 6 - if w > 100 { - w = 100 - } - if w < 20 { - w = 20 - } - if h < 5 { - h = 5 - } + w = min(detailsDialogWidth, max(m.width-4, 20)) + h = min(detailsDialogHeight, max(m.height-2, detailsChromeRows+1)) return w, h } -// resizeDetails keeps the viewport sized to the current dialog dimensions. -// The viewport content area sits inside the dialog border (1) and padding (2) -// on each side, plus a title and help line (3 rows). +// 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.detailsDialogSize() - m.details.SetWidth(w - 2*(1+2)) - m.details.SetHeight(h - 2*1 - 3) + 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 @@ -242,6 +266,7 @@ func (m *agentPickerModel) openDetails() { m.resizeDetails() m.details.SetContent(m.detailsContent(m.choices[m.cursor])) m.details.GotoTop() + m.syncDetailsBar() m.showDetails = true } @@ -334,26 +359,38 @@ func (m *agentPickerModel) render() string { // renderDetails renders the scrollable YAML dialog for the selected agent. func (m *agentPickerModel) renderDetails() string { - w, _ := m.detailsDialogSize() - contentWidth := w - 2*(1+2) + dw, _ := m.detailsDialogSize() + contentWidth := dw - detailsChromeCols + scrollbar.Width ref := m.choices[m.cursor].ref title := styles.DialogTitleStyle.Width(contentWidth).Render(toolcommon.TruncateText(ref, contentWidth)) - scrollHint := "" - if !m.details.AtTop() || !m.details.AtBottom() { - scrollHint = " • " + percentLabel(m.details.ScrollPercent()) + // 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") } - help := styles.DialogHelpStyle.Render("↑↓ scroll" + scrollHint + " esc/? close") + 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, - m.details.View(), + body, help, ) - return styles.DialogStyle.Width(w - 2).Render(content) + return styles.DialogStyle.Render(content) } // percentLabel formats a scroll fraction (0..1) as a percentage string. diff --git a/cmd/root/agent_picker_test.go b/cmd/root/agent_picker_test.go index 6c48451ac..301d9f7e8 100644 --- a/cmd/root/agent_picker_test.go +++ b/cmd/root/agent_picker_test.go @@ -2,9 +2,11 @@ package root import ( "errors" + "strconv" "strings" "testing" + "charm.land/lipgloss/v2" "github.com/stretchr/testify/assert" ) @@ -93,6 +95,36 @@ func TestPercentLabel(t *testing.T) { 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 TestAgentPickerModelNavigation(t *testing.T) { m := newAgentPickerModel([]agentChoice{ {ref: "default"}, From 689aa5f575a021bd84fdb99e6ae823662332ae51 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 30 May 2026 13:52:23 +0200 Subject: [PATCH 4/5] feat: add YAML syntax highlighting to agent picker details dialog --- cmd/root/agent_picker.go | 41 ++++++++++++++++++++++++++++++++++- cmd/root/agent_picker_test.go | 23 ++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go index 54f75300d..0cbc41b7d 100644 --- a/cmd/root/agent_picker.go +++ b/cmd/root/agent_picker.go @@ -10,6 +10,8 @@ import ( "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" @@ -274,7 +276,7 @@ func (m *agentPickerModel) openDetails() { func (m *agentPickerModel) detailsContent(choice agentChoice) string { switch { case choice.yaml != "": - return strings.TrimRight(choice.yaml, "\n") + return highlightYAML(strings.TrimRight(choice.yaml, "\n")) case choice.err != nil: return "Failed to load agent:\n\n" + choice.err.Error() default: @@ -282,6 +284,43 @@ func (m *agentPickerModel) detailsContent(choice agentChoice) string { } } +// highlightYAML syntax-colorizes YAML using chroma with the active TUI theme. +// On any tokenisation error it returns the source unchanged. +func highlightYAML(src string) string { + 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() +} + +// 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 { diff --git a/cmd/root/agent_picker_test.go b/cmd/root/agent_picker_test.go index 301d9f7e8..7b03b6319 100644 --- a/cmd/root/agent_picker_test.go +++ b/cmd/root/agent_picker_test.go @@ -7,6 +7,7 @@ import ( "testing" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" ) @@ -77,16 +78,34 @@ func TestAgentPickerDetailsToggle(t *testing.T) { assert.False(t, m.showDetails) m.openDetails() assert.True(t, m.showDetails) - assert.Contains(t, m.details.GetContent(), "model: auto") + assert.Contains(t, ansi.Strip(m.details.GetContent()), "model: auto") } func TestDetailsContent(t *testing.T) { m := newAgentPickerModel(nil) - assert.Equal(t, "a: b", m.detailsContent(agentChoice{yaml: "a: b\n\n"})) + // 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)) From b73fb887fd343514eb6ade6485bb9114b4df22ce Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sat, 30 May 2026 14:00:40 +0200 Subject: [PATCH 5/5] fix: sanitize agent config display and enable YAML soft-wrap Sanitize YAML, descriptions, and error messages from potentially remote agent configs before display: normalize line endings, expand tabs, and strip control characters (ESC, etc.) to prevent terminal escape injection. Also enable SoftWrap in the YAML viewport so long lines wrap instead of scrolling off-screen. Adds tests for stripControl, sanitizeYAML, and escape-injection scenarios. --- cmd/root/agent_picker.go | 39 +++++++++++++++++++++++++++++------ cmd/root/agent_picker_test.go | 28 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/cmd/root/agent_picker.go b/cmd/root/agent_picker.go index 0cbc41b7d..00255e2e1 100644 --- a/cmd/root/agent_picker.go +++ b/cmd/root/agent_picker.go @@ -152,6 +152,7 @@ type agentPickerModel struct { func newAgentPickerModel(choices []agentChoice) *agentPickerModel { vp := viewport.New() vp.FillHeight = true + vp.SoftWrap = true return &agentPickerModel{ choices: choices, details: vp, @@ -278,15 +279,16 @@ func (m *agentPickerModel) detailsContent(choice agentChoice) string { case choice.yaml != "": return highlightYAML(strings.TrimRight(choice.yaml, "\n")) case choice.err != nil: - return "Failed to load agent:\n\n" + choice.err.Error() + 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 source unchanged. +// 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 @@ -304,6 +306,30 @@ func highlightYAML(src string) string { 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 { @@ -477,11 +503,12 @@ func (m *agentPickerModel) renderCard(choice agentChoice, cardWidth int, selecte Render(card) } -// truncateDetail collapses whitespace (including newlines) into single spaces -// 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. +// 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(strings.Join(strings.Fields(text), " "), width) + return toolcommon.TruncateText(stripControl(strings.Join(strings.Fields(text), " ")), width) } // prependAgentRef returns args with ref inserted as the leading positional diff --git a/cmd/root/agent_picker_test.go b/cmd/root/agent_picker_test.go index 7b03b6319..20259fbc7 100644 --- a/cmd/root/agent_picker_test.go +++ b/cmd/root/agent_picker_test.go @@ -144,6 +144,34 @@ func TestAgentPickerDetailsFixedSize(t *testing.T) { 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"},