diff --git a/cmd/root/run.go b/cmd/root/run.go index b5efa8bd5d..89112e1f21 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -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" @@ -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 @@ -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)") @@ -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)) } diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index a41b354f6e..9778cd64e4 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -42,6 +42,7 @@ $ docker agent run [config] [message...] [flags] | `--session-workingdir-root ` | 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 ` | 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 ` | 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 ` | 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) | @@ -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" diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index 831f91aed6..41b66d3dae 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -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`: diff --git a/pkg/tui/components/sidebar/command_sections.go b/pkg/tui/components/sidebar/command_sections.go new file mode 100644 index 0000000000..1bd2cff668 --- /dev/null +++ b/pkg/tui/components/sidebar/command_sections.go @@ -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) +} diff --git a/pkg/tui/components/sidebar/command_sections_test.go b/pkg/tui/components/sidebar/command_sections_test.go new file mode 100644 index 0000000000..55fca99b5d --- /dev/null +++ b/pkg/tui/components/sidebar/command_sections_test.go @@ -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)) +} diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index ec2e134129..0c246410b4 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -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) @@ -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. @@ -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. @@ -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() @@ -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) diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 76683bd25a..12f9ccee96 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -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) { diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 68e5f5c018..065b2fda91 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -33,6 +33,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/editor" "github.com/docker/docker-agent/pkg/tui/components/editor/completions" "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/components/sidebar" "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/statusbar" "github.com/docker/docker-agent/pkg/tui/components/tabbar" @@ -285,6 +286,8 @@ type appModel struct { // showBanner displays the ASCII-art startup banner on an empty // conversation. Shared by every tab and managed via /settings. showBanner bool + // sidebarSections are extra sidebar sections fed by shell commands. + sidebarSections []sidebar.CommandSection // buildCommandCategories is a function that returns the list of command categories. buildCommandCategories func(context.Context, tea.Model) []commands.Category @@ -390,6 +393,14 @@ func WithTourOffer(showTelemetryNotice bool) Option { } } +// WithSidebarSections adds sidebar sections that show a shell command's +// output, refreshed every few seconds. +func WithSidebarSections(sections []sidebar.CommandSection) Option { + return func(m *appModel) { + m.sidebarSections = sections + } +} + // WithAppName sets the application name. // // If not provided, defaults to "docker agent". @@ -667,6 +678,7 @@ func (m *appModel) chatPageOpts() []chat.PageOption { chat.WithSendMode(m.sendMode), chat.WithInterruptMode(m.interruptMode), chat.WithShowBanner(m.showBanner), + chat.WithSidebarSections(m.sidebarSections), } if m.leanMode {