Skip to content
Merged
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
20 changes: 3 additions & 17 deletions pkg/leantui/banner.go
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions pkg/tui/banner/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package banner

const (
Width = 56
Height = 13
)

// Lines is the Docker Agent ASCII-art startup banner.
var Lines = []string{
`██████╗ ██████╗ ██████╗██╗ ██╗███████╗██████╗ `,
`██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗`,
`██║ ██║██║ ██║██║ █████╔╝ █████╗ ██████╔╝`,
`██║ ██║██║ ██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗`,
`██████╔╝╚██████╔╝╚██████╗██║ ██╗███████╗██║ ██║`,
`╚═════╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝`,
``,
` █████╗ ██████╗ ███████╗███╗ ██╗████████╗`,
` ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝`,
` ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ `,
` ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ `,
` ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ `,
` ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ `,
}
40 changes: 31 additions & 9 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment thread
rumpl marked this conversation as resolved.

// Message queue for enqueuing messages while agent is working
messageQueue []queuedMessage
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand Down
114 changes: 109 additions & 5 deletions pkg/tui/page/chat/layout_position_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions pkg/tui/page/chat/runtime_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
Loading