diff --git a/pkg/leantui/banner.go b/pkg/leantui/banner.go index 4c94ef439..051e44cf4 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 000000000..b6f28e1a6 --- /dev/null +++ b/pkg/tui/banner/banner.go @@ -0,0 +1,23 @@ +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 663e45812..82754533f 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 @@ -374,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 { @@ -723,11 +726,30 @@ 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 + } + 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( + 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 62cc57cac..4890f4cca 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" @@ -23,16 +24,119 @@ 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() + + 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 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 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() + + 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 faccd805f..1b07ea65f 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,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) + if msg.WelcomeMessage != "" { + p.showStartupBanner = false + } p.messages.AddWelcomeMessage(msg.WelcomeMessage) return true, sidebarCmd