From 47ffa3b6ec14216430d8c3576a37b24543283a24 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 08:40:13 +0200 Subject: [PATCH 1/3] feat: add startup banner to normal TUI Signed-off-by: Djordje Lukic --- pkg/leantui/banner.go | 20 ++------ pkg/tui/banner/banner.go | 18 ++++++++ pkg/tui/page/chat/chat.go | 20 +++++++- pkg/tui/page/chat/layout_position_test.go | 56 +++++++++++++++++++++++ pkg/tui/page/chat/runtime_events.go | 2 + 5 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 pkg/tui/banner/banner.go diff --git a/pkg/leantui/banner.go b/pkg/leantui/banner.go index 4c94ef4398..051e44cf42 100644 --- a/pkg/leantui/banner.go +++ b/pkg/leantui/banner.go @@ -1,24 +1,10 @@ package leantui +import tuibanner "github.com/docker/docker-agent/pkg/tui/banner" + const ( bannerTopPadding = 1 bannerLeftPadding = 2 ) -// bannerLines is a pre-rendered ASCII-art banner for the lean TUI welcome -// screen. Each unpadded line fits within 56 columns. -var bannerLines = []string{ - `██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `, - `██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗`, - `██║ ██║██║ ██║██║ █████╔╝ █████╗ ██████╔╝`, - `██║ ██║██║ ██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗`, - `██████╔╝╚██████╔╝╚██████╗██║ ██╗███████╗██║ ██║`, - `╚═════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝`, - ``, - ` █████╗ ██████╗ ███████╗███╗ ██╗████████╗`, - ` ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝`, - ` ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ `, - ` ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ `, - ` ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ `, - ` ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ `, -} +var bannerLines = tuibanner.Lines diff --git a/pkg/tui/banner/banner.go b/pkg/tui/banner/banner.go new file mode 100644 index 0000000000..ad972ce898 --- /dev/null +++ b/pkg/tui/banner/banner.go @@ -0,0 +1,18 @@ +package banner + +// Lines is the Docker Agent ASCII-art startup banner. +var Lines = []string{ + `██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `, + `██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗`, + `██║ ██║██║ ██║██║ █████╔╝ █████╗ ██████╔╝`, + `██║ ██║██║ ██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗`, + `██████╔╝╚██████╔╝╚██████╗██║ ██╗███████╗██║ ██║`, + `╚═════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝`, + ``, + ` █████╗ ██████╗ ███████╗███╗ ██╗████████╗`, + ` ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝`, + ` ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ `, + ` ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ `, + ` ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ `, + ` ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ `, +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 663e45812f..72cb754352 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -15,6 +15,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/tui/animation" + tuibanner "github.com/docker/docker-agent/pkg/tui/banner" "github.com/docker/docker-agent/pkg/tui/commands" "github.com/docker/docker-agent/pkg/tui/components/messages" "github.com/docker/docker-agent/pkg/tui/components/notification" @@ -230,6 +231,7 @@ type chatPage struct { // Track whether we've received content from an assistant response // Used by --exit-after-response to ensure we don't exit before receiving content hasReceivedAssistantContent bool + showStartupBanner bool // Message queue for enqueuing messages while agent is working messageQueue []queuedMessage @@ -723,11 +725,27 @@ func (p *chatPage) renderCollapsedSidebar(sl sidebarLayout) string { Render(sidebarWithDivider) } +func (p *chatPage) messagesView(sl sidebarLayout) string { + messagesView := p.messages.View() + if messagesView != "" || !p.showStartupBanner { + return messagesView + } + + banner := styles.BaseStyle.Foreground(styles.Accent).Render(strings.Join(tuibanner.Lines, "\n")) + return lipgloss.Place( + sl.chatWidth, + sl.chatHeight, + lipgloss.Center, + lipgloss.Center, + banner, + ) +} + // View renders the chat page (messages + sidebar only, no editor or resize handle) func (p *chatPage) View() string { sl := p.computeSidebarLayout() - messagesView := p.messages.View() + messagesView := p.messagesView(sl) var bodyContent string diff --git a/pkg/tui/page/chat/layout_position_test.go b/pkg/tui/page/chat/layout_position_test.go index 62cc57cac1..f92e737752 100644 --- a/pkg/tui/page/chat/layout_position_test.go +++ b/pkg/tui/page/chat/layout_position_test.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tui/animation" + tuibanner "github.com/docker/docker-agent/pkg/tui/banner" "github.com/docker/docker-agent/pkg/tui/components/messages" "github.com/docker/docker-agent/pkg/tui/components/sidebar" msgtypes "github.com/docker/docker-agent/pkg/tui/messages" @@ -33,6 +34,61 @@ func newLayoutTestPage(t *testing.T, position msgtypes.SidebarPosition) *chatPag return p } +func TestStartupBannerIsCenteredInEmptyChat(t *testing.T) { + t.Parallel() + + p := newLayoutTestPage(t, msgtypes.SidebarRight) + p.showStartupBanner = true + p.SetSize(p.width, p.height) + sl := p.computeSidebarLayout() + + lines := strings.Split(p.messagesView(sl), "\n") + require.Len(t, lines, sl.chatHeight) + + firstBannerLine := -1 + for i, line := range lines { + if strings.Contains(ansi.Strip(line), tuibanner.Lines[0]) { + firstBannerLine = i + break + } + } + require.NotEqual(t, -1, firstBannerLine) + assert.Equal(t, (sl.chatHeight-len(tuibanner.Lines))/2, firstBannerLine) + assert.Equal(t, (sl.chatWidth-ansi.StringWidth(tuibanner.Lines[0]))/2, + strings.Index(ansi.Strip(lines[firstBannerLine]), "█")) +} + +func TestAgentWelcomeMessageControlsStartupBanner(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + welcomeMessage string + wantBanner bool + }{ + {name: "empty welcome", wantBanner: true}, + {name: "configured welcome", welcomeMessage: "Welcome to the agent"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newLayoutTestPage(t, msgtypes.SidebarRight) + event := runtime.AgentInfo("root", "model", "", tt.welcomeMessage) + handled, _ := p.handleRuntimeEvent(event) + + require.True(t, handled) + assert.Equal(t, tt.wantBanner, p.showStartupBanner) + out := ansi.Strip(p.messagesView(p.computeSidebarLayout())) + if tt.wantBanner { + assert.Contains(t, out, tuibanner.Lines[0]) + } else { + assert.Contains(t, out, tt.welcomeMessage) + assert.NotContains(t, out, tuibanner.Lines[0]) + } + }) + } +} + func TestComputeSidebarLayout_RightDefault(t *testing.T) { t.Parallel() diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index faccd805fe..a11715f156 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -83,6 +83,7 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) { // ===== Content Events ===== case *runtime.UserMessageEvent: + p.showStartupBanner = false return true, p.messages.ReplaceLoadingWithUser(msg.Message, msg.SessionPosition) case *runtime.AgentChoiceEvent: @@ -117,6 +118,7 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) { case *runtime.AgentInfoEvent: sidebarCmd := p.sidebar.SetAgentInfo(msg.AgentName, msg.Model, msg.Description, msg.ContextLimit, msg.CompactionModel, msg.PrimaryContextLimit) + p.showStartupBanner = msg.WelcomeMessage == "" p.messages.AddWelcomeMessage(msg.WelcomeMessage) return true, sidebarCmd From 3feae0089371adcc730fdce911d5e7f6565ca161 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 08:46:32 +0200 Subject: [PATCH 2/3] fix: hide startup banner in small viewports Signed-off-by: Djordje Lukic --- pkg/tui/banner/banner.go | 5 +++++ pkg/tui/page/chat/chat.go | 3 +++ pkg/tui/page/chat/layout_position_test.go | 25 +++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/pkg/tui/banner/banner.go b/pkg/tui/banner/banner.go index ad972ce898..b6f28e1a6d 100644 --- a/pkg/tui/banner/banner.go +++ b/pkg/tui/banner/banner.go @@ -1,5 +1,10 @@ package banner +const ( + Width = 56 + Height = 13 +) + // Lines is the Docker Agent ASCII-art startup banner. var Lines = []string{ `██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `, diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 72cb754352..8ba14472d3 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -730,6 +730,9 @@ func (p *chatPage) messagesView(sl sidebarLayout) string { if messagesView != "" || !p.showStartupBanner { return messagesView } + if sl.chatWidth < tuibanner.Width || sl.chatHeight < tuibanner.Height { + return "" + } banner := styles.BaseStyle.Foreground(styles.Accent).Render(strings.Join(tuibanner.Lines, "\n")) return lipgloss.Place( diff --git a/pkg/tui/page/chat/layout_position_test.go b/pkg/tui/page/chat/layout_position_test.go index f92e737752..1ef645d24a 100644 --- a/pkg/tui/page/chat/layout_position_test.go +++ b/pkg/tui/page/chat/layout_position_test.go @@ -58,6 +58,31 @@ func TestStartupBannerIsCenteredInEmptyChat(t *testing.T) { strings.Index(ansi.Strip(lines[firstBannerLine]), "█")) } +func TestStartupBannerHiddenWhenChatIsTooSmall(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + width int + height int + }{ + {name: "too narrow", width: tuibanner.Width - 1, height: tuibanner.Height}, + {name: "too short", width: tuibanner.Width, height: tuibanner.Height - 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newLayoutTestPage(t, msgtypes.SidebarRight) + p.showStartupBanner = true + + assert.Empty(t, p.messagesView(sidebarLayout{ + chatWidth: tt.width, + chatHeight: tt.height, + })) + }) + } +} + func TestAgentWelcomeMessageControlsStartupBanner(t *testing.T) { t.Parallel() From 4b2fd8b1ef5a68d4ddc4aea19d1622a553bcc3d0 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Fri, 7 Aug 2026 09:49:14 +0200 Subject: [PATCH 3/3] fix: show banner before startup info Signed-off-by: Djordje Lukic --- pkg/tui/page/chat/chat.go | 17 ++++++------ pkg/tui/page/chat/layout_position_test.go | 33 +++++++++++++++++++---- pkg/tui/page/chat/runtime_events.go | 4 ++- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 8ba14472d3..82754533f0 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -376,14 +376,15 @@ func defaultKeyMap() KeyMap { // New creates a new chat page func New(ar *animation.Runtime, ctx context.Context, a *app.App, sessionState *service.SessionState, opts ...PageOption) Page { p := &chatPage{ - ar: ar, - ctx: func() context.Context { return context.WithoutCancel(ctx) }, - sidebar: sidebar.New(ar, ctx, sessionState), - messages: messages.New(ar, sessionState), - app: a, - keyMap: defaultKeyMap(), - commandParser: commands.NewParser(), - sessionState: sessionState, + ar: ar, + ctx: func() context.Context { return context.WithoutCancel(ctx) }, + sidebar: sidebar.New(ar, ctx, sessionState), + messages: messages.New(ar, sessionState), + app: a, + keyMap: defaultKeyMap(), + commandParser: commands.NewParser(), + sessionState: sessionState, + showStartupBanner: true, } for _, opt := range opts { diff --git a/pkg/tui/page/chat/layout_position_test.go b/pkg/tui/page/chat/layout_position_test.go index 1ef645d24a..4890f4ccaf 100644 --- a/pkg/tui/page/chat/layout_position_test.go +++ b/pkg/tui/page/chat/layout_position_test.go @@ -24,16 +24,28 @@ func newLayoutTestPage(t *testing.T, position msgtypes.SidebarPosition) *chatPag t.Helper() sessionState := &service.SessionState{} p := &chatPage{ - sidebar: sidebar.New(animation.NewRuntime(), t.Context(), sessionState), - messages: messages.New(animation.NewRuntime(), sessionState), - sessionState: sessionState, - width: 160, - height: 40, + sidebar: sidebar.New(animation.NewRuntime(), t.Context(), sessionState), + messages: messages.New(animation.NewRuntime(), sessionState), + sessionState: sessionState, + width: 160, + height: 40, + showStartupBanner: true, } p.layoutSettings = msgtypes.LayoutSettings{SidebarPosition: position} return p } +func TestNewChatPageShowsBannerBeforeAgentInfo(t *testing.T) { + t.Parallel() + + sessionState := &service.SessionState{} + p := New(animation.NewRuntime(), t.Context(), nil, sessionState).(*chatPage) + p.SetSize(160, 40) + + assert.True(t, p.showStartupBanner) + assert.Contains(t, ansi.Strip(p.messagesView(p.computeSidebarLayout())), tuibanner.Lines[0]) +} + func TestStartupBannerIsCenteredInEmptyChat(t *testing.T) { t.Parallel() @@ -83,6 +95,17 @@ func TestStartupBannerHiddenWhenChatIsTooSmall(t *testing.T) { } } +func TestEmptyAgentInfoDoesNotReactivateStartupBanner(t *testing.T) { + t.Parallel() + + p := newLayoutTestPage(t, msgtypes.SidebarRight) + p.showStartupBanner = false + handled, _ := p.handleRuntimeEvent(runtime.AgentInfo("root", "model", "", "")) + + require.True(t, handled) + assert.False(t, p.showStartupBanner) +} + func TestAgentWelcomeMessageControlsStartupBanner(t *testing.T) { t.Parallel() diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index a11715f156..1b07ea65f0 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -118,7 +118,9 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) { case *runtime.AgentInfoEvent: sidebarCmd := p.sidebar.SetAgentInfo(msg.AgentName, msg.Model, msg.Description, msg.ContextLimit, msg.CompactionModel, msg.PrimaryContextLimit) - p.showStartupBanner = msg.WelcomeMessage == "" + if msg.WelcomeMessage != "" { + p.showStartupBanner = false + } p.messages.AddWelcomeMessage(msg.WelcomeMessage) return true, sidebarCmd