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
10 changes: 10 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"
tuibanner "github.com/docker/docker-agent/pkg/tui/banner"
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
bannerFile 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().StringVar(&flags.bannerFile, "banner-file", "", "Text file drawn in the TUI in place of the startup banner; frames separated by a form-feed line play as an animation")
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,13 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s

var rec *recorder.Recorder
tuiOptions := f.tuiOpts(args)
if f.bannerFile != "" {
b, err := tuibanner.Load(f.bannerFile)
if err != nil {
return err
}
tuiOptions = append(tuiOptions, tui.WithBanner(b))
}
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). |
| `--banner-file <path>` | Draw the art in this text file in the TUI in place of the startup banner; frames separated by a form-feed line play as an animation. |
| `--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 --banner-file ./banner.txt
$ docker agent run agent.yaml --sidebar=false
$ docker agent run agent.yaml --disable-commands="/cost,/eval,/model"

Expand Down
5 changes: 5 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ _Docker Agent's default interface is a rich, interactive terminal UI with file a

Both the full TUI and the lean TUI display a centered ASCII art Docker Agent banner on startup when the chat is empty. The banner is automatically hidden once the agent starts responding or when a custom welcome message is configured. You can disable the banner entirely from the **Appearance** tab of `/settings`, or by setting `show_banner: false` in your [user settings](../../configuration/user-settings/index.md).

To draw your own art in the full TUI instead, pass `--banner-file <path>` with a text file. A line holding only a form feed (`\f`) separates frames; a file with several frames plays them in a loop, one every 100ms, until the conversation starts. Lines may carry ANSI color sequences. Art larger than the chat area is cropped around its center, so a backdrop sized for a large terminal fills a small one. The lean TUI keeps the default banner.

```bash
# Launch with a config
$ docker agent run agent.yaml
Expand All @@ -31,6 +33,9 @@ $ docker agent run agent.yaml --debug
# Override the application name shown in the status bar and window title
$ docker agent run agent.yaml --app-name "My Project"

# Replace the startup banner with your own art (several frames play as an animation)
$ docker agent run agent.yaml --banner-file ./banner.txt

# Preselect a color theme
$ docker agent run agent.yaml --theme dracula

Expand Down
140 changes: 140 additions & 0 deletions pkg/tui/banner/banner.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
// Package banner holds the art drawn on an empty conversation.
package banner

import (
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/charmbracelet/x/ansi"
)

const (
Width = 56
Height = 13
)

// FrameDuration is how long each frame of an animated banner is shown.
const FrameDuration = 100 * time.Millisecond

// FrameSeparator is the line that separates the frames of a banner file.
const FrameSeparator = "\f"

// maxFileSize bounds a banner file; the art is redrawn on every frame change.
const maxFileSize = 16 << 20

// Lines is the Docker Agent ASCII-art startup banner.
var Lines = []string{
`██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `,
Expand All @@ -21,3 +41,123 @@ var Lines = []string{
` ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ `,
` ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ `,
}

// Banner is one or more frames of art. Every frame is padded to the same
// size, so the banner does not move as it plays.
type Banner struct {
frames []string
width int
height int
// crops marks art that is cut down to an area smaller than itself
// rather than left out.
crops bool
}

// Default returns the Docker Agent banner.
func Default() Banner {
return Banner{frames: []string{strings.Join(Lines, "\n")}, width: Width, height: Height}
}

// Load reads a banner from a text file. See Parse for the format.
func Load(path string) (Banner, error) {
info, err := os.Stat(path)
if err != nil {
return Banner{}, fmt.Errorf("reading banner file: %w", err)
}
if info.Size() > maxFileSize {
return Banner{}, fmt.Errorf("banner file %s is larger than %d bytes", path, maxFileSize)
}
data, err := os.ReadFile(path)
if err != nil {
return Banner{}, fmt.Errorf("reading banner file: %w", err)
}
b, err := Parse(string(data))
if err != nil {
return Banner{}, fmt.Errorf("banner file %s: %w", path, err)
}
return b, nil
}

// Parse builds a banner from text. A line holding only a form feed separates
// frames; a banner with several frames plays them in a loop. Lines may carry
// ANSI color sequences.
func Parse(text string) (Banner, error) {
text = strings.ReplaceAll(text, "\r\n", "\n")
var frames [][]string
var width, height int
for chunk := range strings.SplitSeq(text, "\n"+FrameSeparator+"\n") {
lines := strings.Split(strings.Trim(chunk, "\n"), "\n")
if len(lines) == 1 && strings.TrimSpace(ansi.Strip(lines[0])) == "" {
continue
}
height = max(height, len(lines))
for _, line := range lines {
width = max(width, ansi.StringWidth(line))
}
frames = append(frames, lines)
}
if len(frames) == 0 {
return Banner{}, errors.New("no banner art found")
}

b := Banner{width: width, height: height, crops: true, frames: make([]string, 0, len(frames))}
for _, lines := range frames {
for i, line := range lines {
lines[i] = line + strings.Repeat(" ", width-ansi.StringWidth(line))
}
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
b.frames = append(b.frames, strings.Join(lines, "\n"))
}
return b, nil
}

func (b Banner) Width() int { return b.width }
func (b Banner) Height() int { return b.height }

// Fits reports whether the banner can be drawn in the given area. Art read
// from a file always can: it is cut down around its center, so a backdrop
// larger than any terminal fills the area whatever its size.
func (b Banner) Fits(width, height int) bool {
if width <= 0 || height <= 0 {
return false
}
return b.crops || (width >= b.width && height >= b.height)
}

// Crop cuts a frame down around its center to at most the given area.
func (b Banner) Crop(frame string, width, height int) string {
if width >= b.width && height >= b.height {
return frame
}
lines := strings.Split(frame, "\n")
if extra := len(lines) - height; extra > 0 {
lines = lines[extra/2 : extra/2+height]
}
if extra := b.width - width; extra > 0 {
for i, line := range lines {
lines[i] = ansi.Cut(line, extra/2, extra/2+width)
}
}
return strings.Join(lines, "\n")
}

// Animated reports whether the banner has more than one frame.
func (b Banner) Animated() bool { return len(b.frames) > 1 }

// FrameIndexAt returns the frame shown after the given elapsed time.
func (b Banner) FrameIndexAt(elapsed time.Duration) int {
if len(b.frames) < 2 || elapsed <= 0 {
return 0
}
return int(elapsed/FrameDuration) % len(b.frames)
}

// FrameAt returns the art shown after the given elapsed time.
func (b Banner) FrameAt(elapsed time.Duration) string {
if len(b.frames) == 0 {
return ""
}
return b.frames[b.FrameIndexAt(elapsed)]
}
98 changes: 98 additions & 0 deletions pkg/tui/banner/banner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package banner

import (
"os"
"path/filepath"
"strings"
"testing"
"time"

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

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

b := Default()
assert.False(t, b.Animated())
assert.Equal(t, Width, b.Width())
assert.Equal(t, Height, b.Height())
assert.Equal(t, strings.Join(Lines, "\n"), b.FrameAt(time.Hour))
}

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

b, err := Parse("\n ab\nabcd\n\n")
require.NoError(t, err)
assert.False(t, b.Animated())
assert.Equal(t, 4, b.Width())
assert.Equal(t, 2, b.Height())
assert.Equal(t, " ab \nabcd", b.FrameAt(0))
}

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

b, err := Parse("ab\ncd\n\f\nxyz\n\f\n\x1b[31mq\x1b[39m\n")
require.NoError(t, err)
assert.True(t, b.Animated())
assert.Equal(t, 3, b.Width(), "color sequences take no width")
assert.Equal(t, 2, b.Height())
assert.Equal(t, "ab \ncd ", b.FrameAt(0))
assert.Equal(t, "xyz\n ", b.FrameAt(FrameDuration))
assert.Equal(t, "\x1b[31mq\x1b[39m \n ", b.FrameAt(2*FrameDuration))
assert.Equal(t, "ab \ncd ", b.FrameAt(3*FrameDuration), "the frames loop")
}

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

b := Default()
assert.True(t, b.Fits(Width, Height))
assert.False(t, b.Fits(Width-1, Height))
assert.False(t, b.Fits(Width, Height-1))
}

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

b, err := Parse("abcdef\n\x1b[31mghijkl\x1b[39m\nmnopqr\nstuvwx")
require.NoError(t, err)
require.True(t, b.Fits(2, 2))
assert.False(t, b.Fits(0, 2))

frame := b.FrameAt(0)
assert.Equal(t, frame, b.Crop(frame, 6, 4), "an area large enough leaves the art whole")
assert.Equal(t, "hijk\nnopq", ansi.Strip(b.Crop(frame, 4, 2)))
assert.Contains(t, b.Crop(frame, 4, 2), "\x1b[31m", "colors survive the cut")
}

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

for _, text := range []string{"", "\n\n", " \n\f\n\n"} {
_, err := Parse(text)
require.Error(t, err)
}
}

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

path := filepath.Join(t.TempDir(), "banner.txt")
require.NoError(t, os.WriteFile(path, []byte("one\r\n\f\r\ntwo\r\n"), 0o600))
b, err := Load(path)
require.NoError(t, err)
assert.Equal(t, "one", b.FrameAt(0))
assert.Equal(t, "two", b.FrameAt(FrameDuration))

_, err = Load(filepath.Join(t.TempDir(), "missing.txt"))
require.Error(t, err)

require.NoError(t, os.WriteFile(path, nil, 0o600))
_, err = Load(path)
require.ErrorContains(t, err, path)
}
Loading