Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/tour"
"github.com/docker/docker-agent/pkg/tui"
"github.com/docker/docker-agent/pkg/tui/components/sidebar"
tuiimage "github.com/docker/docker-agent/pkg/tui/image"
"github.com/docker/docker-agent/pkg/tui/recorder"
"github.com/docker/docker-agent/pkg/tui/styles"
Expand Down Expand Up @@ -117,6 +118,7 @@ type runExecFlags struct {
lean bool
leanChanged bool
appName string
sidebarSections []string
sidebar bool
listenAddr string
// sessionWorkingDirRoot confines the working_dir of sessions created
Expand Down Expand Up @@ -211,6 +213,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
_ = cmd.PersistentFlags().MarkHidden("tour")
cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome")
cmd.PersistentFlags().StringVar(&flags.appName, "app-name", "", "Application name shown in the TUI in place of \"docker agent\"")
cmd.PersistentFlags().StringArrayVar(&flags.sidebarSections, "sidebar-section", nil, "Add a sidebar section that shows a shell command's output, refreshed every few seconds: \"Title=command\" (repeatable)")
cmd.PersistentFlags().StringSliceVar(&flags.disabledCommands, "disable-commands", nil, "Comma-separated list of slash commands to hide and disable in the TUI (e.g. /cost,/eval,/model)")
cmd.PersistentFlags().BoolVar(&flags.sidebar, "sidebar", true, "Show the sidebar in the TUI (set --sidebar=false to hide it)")
cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name, or \"auto\" to match the terminal's light/dark background (overrides the theme from user config; ignored outside the interactive TUI)")
Expand Down Expand Up @@ -591,6 +594,17 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s

var rec *recorder.Recorder
tuiOptions := f.tuiOpts(args)
if len(f.sidebarSections) > 0 {
sections := make([]sidebar.CommandSection, 0, len(f.sidebarSections))
for _, spec := range f.sidebarSections {
section, err := sidebar.ParseCommandSection(spec)
if err != nil {
return fmt.Errorf("--sidebar-section %q: %w", spec, err)
}
sections = append(sections, section)
}
tuiOptions = append(tuiOptions, tui.WithSidebarSections(sections))
}
if dir := f.explicitDefaultWorkingDir(sess); dir != "" {
tuiOptions = append(tuiOptions, tui.WithDefaultWorkingDir(dir))
}
Expand Down
2 changes: 2 additions & 0 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ $ docker agent run [config] [message...] [flags]
| `--session-workingdir-root <path>` | Confine the `working_dir` of sessions created through the `--listen` control plane to this directory and its descendants (default: no restriction — any clean host directory is accepted, though raw values containing `..` are rejected). Recommended when the control plane is reachable by other users. Hidden from `--help`, like `--listen`. |
| `--lean` | Use a simplified, non-alternate-screen TUI. Unlike the default full-screen TUI, this renders inline in the normal terminal buffer — useful in environments where an alternate screen is unwanted (e.g. inside tmux panes, CI with a tty, or log-friendly pipelines). Like the full TUI, displays an ASCII art banner on startup when the chat is empty (configurable via `show_banner` in [user settings](../../configuration/user-settings/index.md)). |
| `--app-name <name>` | Override the application name label shown in the TUI (status bar, window title, "/exit" notifications). |
| `--sidebar-section "Title=cmd"` | Add a sidebar section that shows a shell command's output, refreshed every few seconds. Repeatable. |
| `--sidebar` | Control sidebar visibility. Set to `--sidebar=false` to hide the sidebar and disable the Ctrl+B toggle (default: `true`). |
| `--disable-commands <list>` | Hide and disable specific slash commands in the TUI. Accepts a comma-separated list of command names (leading slash optional, case-insensitive). E.g. `--disable-commands="/cost,/eval,/model"`. |
| `--theme <name>` | Preselect a TUI theme by name, or `auto` to follow the terminal's light/dark background (overrides the theme from user config; ignored outside the interactive TUI) |
Expand Down Expand Up @@ -98,6 +99,7 @@ $ docker agent run agent.yaml "question 1" "question 2" "question 3"

# Customize TUI display
$ docker agent run agent.yaml --app-name "My Project"
$ docker agent run agent.yaml --sidebar-section "Services=docker compose ps --format '{{.Name}} {{.State}}'"
$ docker agent run agent.yaml --sidebar=false
$ docker agent run agent.yaml --disable-commands="/cost,/eval,/model"

Expand Down
8 changes: 8 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ Plans are shared documents from the same store used by `/plans`, the [plan tools

Editing changes only the plan document. It does not approve a plan, execute its steps, or authorize tool writes.

### Custom Sidebar Sections

Pass `--sidebar-section "Title=command"` to add a section below Tools that shows a shell command's output. The command runs when the TUI starts and again every 5 seconds, with a 5 second timeout. Its output is shown as plain text: color sequences are stripped, long lines are cut to the sidebar's width, and at most 12 lines are kept. A section with no output is hidden. The flag is repeatable.

```bash
$ docker agent run agent.yaml --sidebar-section "Services=docker compose ps --format '{{.Name}} {{.State}}'"
```

### Agents Panel

The sidebar's **Agents** section lists every agent in the team and has two display modes selectable via **Sidebar info mode** in `/settings`:
Expand Down
136 changes: 136 additions & 0 deletions pkg/tui/components/sidebar/command_sections.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package sidebar

import (
"context"
"errors"
"os/exec"
"slices"
"strings"
"time"

tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"

"github.com/docker/docker-agent/pkg/shellpath"
"github.com/docker/docker-agent/pkg/tui/styles"
)

const (
// commandSectionInterval is how long a command section waits between runs.
commandSectionInterval = 5 * time.Second
// commandSectionTimeout bounds one run, so a hung command cannot stall the next.
commandSectionTimeout = 5 * time.Second
// commandSectionMaxLines keeps a chatty command from pushing the other sections away.
commandSectionMaxLines = 12
)

// CommandSection is a sidebar section whose body is the output of a shell
// command, run again every few seconds.
type CommandSection struct {
Title string
Command string
}

// ParseCommandSection reads a "Title=command" flag value.
func ParseCommandSection(spec string) (CommandSection, error) {
title, command, ok := strings.Cut(spec, "=")
title, command = strings.TrimSpace(title), strings.TrimSpace(command)
if !ok || title == "" || command == "" {
return CommandSection{}, errors.New(`a sidebar section is written "Title=command"`)
}
return CommandSection{Title: title, Command: command}, nil
}

type commandSectionState struct {
CommandSection

lines []string
}

type commandSectionResultMsg struct {
index int
lines []string
}

type commandSectionDueMsg struct{ index int }

func (m *model) SetCommandSections(sections []CommandSection) {
m.commandSections = make([]commandSectionState, len(sections))
for i, section := range sections {
m.commandSections[i].CommandSection = section
}
m.invalidateCache()
}

// pollCommandSections starts every command section's first run. Init runs
// on every tab switch, so only the first call starts the chain.
func (m *model) pollCommandSections() tea.Cmd {
if m.commandSectionsPolling || len(m.commandSections) == 0 {
return nil
}
m.commandSectionsPolling = true
cmds := make([]tea.Cmd, len(m.commandSections))
for i := range m.commandSections {
cmds[i] = m.runCommandSection(i)
}
return tea.Batch(cmds...)
}

func (m *model) runCommandSection(index int) tea.Cmd {
if index < 0 || index >= len(m.commandSections) {
return nil
}
command, parent := m.commandSections[index].Command, m.ctx()
return func() tea.Msg {
ctx, cancel := context.WithTimeout(parent, commandSectionTimeout)
defer cancel()
shell, args := shellpath.DetectShell()
// A failing command still reports what it printed; none means no section.
out, _ := exec.CommandContext(ctx, shell, append(args, command)...).Output()
return commandSectionResultMsg{index: index, lines: commandSectionLines(string(out))}
}
}

// commandSectionLines keeps the printable text of a command's output.
func commandSectionLines(out string) []string {
var lines []string
for line := range strings.SplitSeq(strings.ReplaceAll(out, "\r\n", "\n"), "\n") {
line = strings.TrimRight(strings.ReplaceAll(ansi.Strip(line), "\t", " "), " ")
if line == "" {
continue
}
if len(lines) == commandSectionMaxLines {
lines[len(lines)-1] = "…"
break
}
lines = append(lines, line)
}
return lines
}

// handleCommandSection applies a finished run and schedules the next one.
func (m *model) handleCommandSection(msg commandSectionResultMsg) tea.Cmd {
if msg.index < 0 || msg.index >= len(m.commandSections) {
return nil
}
if state := &m.commandSections[msg.index]; !slices.Equal(state.lines, msg.lines) {
state.lines = msg.lines
m.invalidateCache()
}
index := msg.index
return tea.Tick(commandSectionInterval, func(time.Time) tea.Msg {
return commandSectionDueMsg{index: index}
})
}

// commandSection renders one command section, or "" while it has no output.
func (m *model) commandSection(state commandSectionState, contentWidth int) string {
if len(state.lines) == 0 {
return ""
}
lines := make([]string, len(state.lines))
for i, line := range state.lines {
lines[i] = styles.BaseStyle.Render(ansi.Truncate(line, contentWidth, "…"))
}
return m.renderTab(state.Title, strings.Join(lines, "\n"), contentWidth)
}
81 changes: 81 additions & 0 deletions pkg/tui/components/sidebar/command_sections_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package sidebar

import (
"runtime"
"strings"
"testing"

"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/tui/animation"
"github.com/docker/docker-agent/pkg/tui/service"
)

func newCommandSectionModel(t *testing.T, sections ...CommandSection) *model {
t.Helper()
m := New(animation.NewRuntime(), t.Context(), &service.SessionState{}).(*model)
m.SetCommandSections(sections)
return m
}

func sectionsText(m *model) string {
return ansi.Strip(strings.Join(m.renderSections(40), "\n"))
}

func TestParseCommandSection(t *testing.T) {
t.Parallel()

section, err := ParseCommandSection(" Apps = status --short | grep a=b ")
require.NoError(t, err)
assert.Equal(t, CommandSection{Title: "Apps", Command: "status --short | grep a=b"}, section)

for _, spec := range []string{"", "Apps", "=status", "Apps= "} {
_, err := ParseCommandSection(spec)
require.Error(t, err, spec)
}
}

func TestCommandSectionShowsItsOutputAndHidesWhileEmpty(t *testing.T) {
t.Parallel()

m := newCommandSectionModel(t, CommandSection{Title: "Apps", Command: "unused"})
assert.NotContains(t, sectionsText(m), "Apps")

next := m.handleCommandSection(commandSectionResultMsg{lines: []string{"relay running", strings.Repeat("x", 80)}})
require.NotNil(t, next, "a result schedules the next run")
text := sectionsText(m)
assert.Contains(t, text, "Apps")
assert.Contains(t, text, "relay running")
assert.NotContains(t, text, strings.Repeat("x", 41), "a long line is cut to the sidebar")

m.handleCommandSection(commandSectionResultMsg{})
assert.NotContains(t, sectionsText(m), "relay running")
}

func TestCommandSectionLinesKeepsPrintableTextAndBoundsIt(t *testing.T) {
t.Parallel()

assert.Equal(t, []string{"a b", "c"}, commandSectionLines("\x1b[31ma\tb\x1b[0m \r\n\n\nc\n"))

lines := commandSectionLines(strings.Repeat("line\n", commandSectionMaxLines+5))
require.Len(t, lines, commandSectionMaxLines)
assert.Equal(t, "…", lines[len(lines)-1])
}

func TestCommandSectionRunsItsCommandOnce(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("the command is written for a POSIX shell")
}

m := newCommandSectionModel(t, CommandSection{Title: "Apps", Command: "echo relay; echo whoami; exit 3"})
cmd := m.pollCommandSections()
require.NotNil(t, cmd)
assert.Nil(t, m.pollCommandSections(), "Init runs again on a tab switch and must not start a second chain")

msg := m.runCommandSection(0)().(commandSectionResultMsg)
assert.Equal(t, []string{"relay", "whoami"}, msg.lines, "a failing command still shows what it printed")
assert.Nil(t, m.runCommandSection(7))
}
20 changes: 17 additions & 3 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ type Model interface {
SetTokenUsage(event *runtime.TokenUsageEvent)
SetTodos(result *tools.ToolCallResult) error
SetMode(mode Mode)
// SetCommandSections adds sections that show a shell command's output.
SetCommandSections(sections []CommandSection)
// SetMirroredPadding swaps the horizontal edge padding so the sidebar hugs
// the terminal edge when rendered on the left of the chat.
SetMirroredPadding(mirrored bool)
Expand Down Expand Up @@ -418,8 +420,11 @@ type model struct {
// from blank-line heuristics.
agentLineOwners []string
planData messages.PlanSidebarDataMsg
recentPlans []plans.Plan
planClickZones map[int]planClickZone
commandSections []commandSectionState
// commandSectionsPolling is set once the sections' refresh chain has started.
commandSectionsPolling bool
recentPlans []plans.Plan
planClickZones map[int]planClickZone
}

// New creates a new sidebar bound to the given session state.
Expand Down Expand Up @@ -464,7 +469,7 @@ func New(ar *animation.Runtime, ctx context.Context, sessionState *service.Sessi
}

func (m *model) Init() tea.Cmd {
return waitForGitBranch(m.gitBranchWatcher)
return tea.Batch(waitForGitBranch(m.gitBranchWatcher), m.pollCommandSections())
}

// needsSpinner returns true if any spinner-driving state is active.
Expand Down Expand Up @@ -1234,6 +1239,12 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
case messages.PlanSidebarDataMsg:
m.setPlans(msg)
return m, nil
case commandSectionResultMsg:
cmd := m.handleCommandSection(msg)
return m, cmd
case commandSectionDueMsg:
cmd := m.runCommandSection(msg.index)
return m, cmd
case gitBranchChangedMsg:
m.gitBranchName = string(msg)
m.invalidateCache()
Expand Down Expand Up @@ -1821,6 +1832,9 @@ func (m *model) renderSections(contentWidth int) []string {
if !m.sectionVisibility.HideTools {
appendSection(m.toolsetInfo(contentWidth))
}
for _, section := range m.commandSections {
appendSection(m.commandSection(section, contentWidth))
}

if !m.sectionVisibility.HideTodos {
m.todoComp.SetSize(contentWidth)
Expand Down
8 changes: 8 additions & 0 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,14 @@ func WithShowBanner(show bool) PageOption {
}
}

// WithSidebarSections adds sidebar sections that show a shell command's
// output, refreshed every few seconds.
func WithSidebarSections(sections []sidebar.CommandSection) PageOption {
return func(p *chatPage) {
p.sidebar.SetCommandSections(sections)
}
}

// WithCommandParser injects a command parser for handling slash commands in the editor.
func WithCommandParser(p *commands.Parser) PageOption {
return func(cp *chatPage) {
Expand Down
Loading