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
33 changes: 22 additions & 11 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/docker/cagent/pkg/app/export"
"github.com/docker/cagent/pkg/app/transcript"
"github.com/docker/cagent/pkg/chat"
"github.com/docker/cagent/pkg/cli"
"github.com/docker/cagent/pkg/config/types"
"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/session"
"github.com/docker/cagent/pkg/tools"
mcptools "github.com/docker/cagent/pkg/tools/mcp"
"github.com/docker/cagent/pkg/tui/messages"
)

type App struct {
Expand Down Expand Up @@ -87,18 +90,26 @@ func New(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts ..
return app
}

func (a *App) FirstMessage() *string {
return a.firstMessage
}
func (a *App) SendFirstMessage() tea.Cmd {
if a.firstMessage == nil {
return nil
}

// FirstMessageAttachment returns the attachment path for the first message.
func (a *App) FirstMessageAttachment() string {
return a.firstMessageAttach
}
return func() tea.Msg {
// Use the shared PrepareUserMessage function for consistent attachment handling
userMsg := cli.PrepareUserMessage(context.Background(), a.runtime, *a.firstMessage, a.firstMessageAttach)

// Runtime returns the runtime for this app.
func (a *App) Runtime() runtime.Runtime {
return a.runtime
// If the message has multi-content (attachments), we need to handle it specially
if len(userMsg.Message.MultiContent) > 0 {
return messages.SendAttachmentMsg{
Content: userMsg,
}
}

return messages.SendMsg{
Content: userMsg.Message.Content,
}
}
}

// CurrentAgentCommands returns the commands for the active agent
Expand Down Expand Up @@ -475,7 +486,7 @@ func (a *App) CompactSession(additionalPrompt string) {
}

func (a *App) PlainTextTranscript() string {
return transcript(a.session)
return transcript.PlainText(a.session)
}

// SessionStore returns the session store for browsing/loading sessions.
Expand Down
4 changes: 2 additions & 2 deletions pkg/app/transcript.go → pkg/app/transcript/transcript.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package app
package transcript

import (
"encoding/json"
Expand All @@ -9,7 +9,7 @@ import (
"github.com/docker/cagent/pkg/session"
)

func transcript(sess *session.Session) string {
func PlainText(sess *session.Session) string {
var builder strings.Builder

messages := sess.GetAllMessages()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package app
package transcript

import (
"testing"
Expand All @@ -12,7 +12,7 @@ import (

func TestSimple(t *testing.T) {
sess := session.New(session.WithUserMessage("Hello"))
content := transcript(sess)
content := PlainText(sess)
golden.Assert(t, content, "simple.golden")
}

Expand All @@ -27,7 +27,7 @@ func TestAssistantMessage(t *testing.T) {
Content: "Hello to you too",
},
})
content := transcript(sess)
content := PlainText(sess)
golden.Assert(t, content, "assistant_message.golden")
}

Expand All @@ -43,7 +43,7 @@ func TestAssistantMessageWithReasoning(t *testing.T) {
ReasoningContent: "Hm....",
},
})
content := transcript(sess)
content := PlainText(sess)
golden.Assert(t, content, "assistant_message_with_reasoning.golden")
}

Expand Down Expand Up @@ -71,7 +71,7 @@ func TestToolCalls(t *testing.T) {
Content: ".\n..",
},
})
content := transcript(sess)
content := PlainText(sess)

golden.Assert(t, content, "tool_calls.golden")
}
9 changes: 2 additions & 7 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/docker/cagent/pkg/tui/components/editor/completions"
"github.com/docker/cagent/pkg/tui/core"
"github.com/docker/cagent/pkg/tui/core/layout"
"github.com/docker/cagent/pkg/tui/messages"
"github.com/docker/cagent/pkg/tui/styles"
)

Expand Down Expand Up @@ -55,12 +56,6 @@ type AttachmentPreview struct {
Content string
}

// SendMsg represents a message to send
type SendMsg struct {
Content string // Full content sent to the agent (with file contents expanded)
Attachments map[string]string // Map of filename to content for attachments
}

// Editor represents an input editor component
type Editor interface {
layout.Model
Expand Down Expand Up @@ -509,7 +504,7 @@ func (e *editor) resetAndSend(content string) tea.Cmd {
e.textarea.Reset()
e.userTyped = false
e.clearSuggestion()
return core.CmdHandler(SendMsg{Content: content, Attachments: attachments})
return core.CmdHandler(messages.SendMsg{Content: content, Attachments: attachments})
}

// configureNewlineKeybinding sets up the appropriate newline keybinding
Expand Down
16 changes: 6 additions & 10 deletions pkg/tui/components/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,12 @@ import (
"github.com/docker/cagent/pkg/tui/components/tool/editfile"
"github.com/docker/cagent/pkg/tui/core"
"github.com/docker/cagent/pkg/tui/core/layout"
"github.com/docker/cagent/pkg/tui/messages"
"github.com/docker/cagent/pkg/tui/service"
"github.com/docker/cagent/pkg/tui/styles"
"github.com/docker/cagent/pkg/tui/types"
)

// StreamCancelledMsg notifies components that the stream has been cancelled
type StreamCancelledMsg struct {
ShowMessage bool // Whether to show a cancellation message after cleanup
}

// ToggleHideToolResultsMsg triggers hiding/showing tool results
type ToggleHideToolResultsMsg struct{}

Expand Down Expand Up @@ -146,7 +142,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
var cmds []tea.Cmd

switch msg := msg.(type) {
case StreamCancelledMsg:
case messages.StreamCancelledMsg:
m.removeSpinner()
m.removePendingToolCallMessages()
return m, nil
Expand Down Expand Up @@ -1185,7 +1181,7 @@ func (m *model) removeSpinner() {
}

func (m *model) removePendingToolCallMessages() {
messages := make([]*types.Message, 0, len(m.messages))
toolCallMessages := make([]*types.Message, 0, len(m.messages))
views := make([]layout.Model, 0, len(m.views))

for i, msg := range m.messages {
Expand All @@ -1194,14 +1190,14 @@ func (m *model) removePendingToolCallMessages() {
continue
}

messages = append(messages, msg)
toolCallMessages = append(toolCallMessages, msg)
if i < len(m.views) {
views = append(views, m.views[i])
}
}

if len(messages) != len(m.messages) {
m.messages = messages
if len(toolCallMessages) != len(m.messages) {
m.messages = toolCallMessages
m.views = views
m.invalidateAllItems()
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/tui/components/sidebar/queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestQueueSection_SingleMessage(t *testing.T) {
sessionState := &service.SessionState{}
m := New(sessionState).(*model)

m.SetQueuedMessages([]string{"Hello world"})
m.SetQueuedMessages("Hello world")

result := m.queueSection(40)

Expand All @@ -39,7 +39,7 @@ func TestQueueSection_MultipleMessages(t *testing.T) {
sessionState := &service.SessionState{}
m := New(sessionState).(*model)

m.SetQueuedMessages([]string{"First", "Second", "Third"})
m.SetQueuedMessages("First", "Second", "Third")

result := m.queueSection(40)

Expand Down Expand Up @@ -67,7 +67,7 @@ func TestQueueSection_LongMessageTruncation(t *testing.T) {

// Create a very long message
longMessage := strings.Repeat("x", 100)
m.SetQueuedMessages([]string{longMessage})
m.SetQueuedMessages(longMessage)

result := m.queueSection(30) // Narrow width to force truncation

Expand All @@ -91,7 +91,7 @@ func TestQueueSection_InRenderSections(t *testing.T) {
assert.NotContains(t, outputWithoutQueue, "Queue")

// With queued messages, queue section should appear
m.SetQueuedMessages([]string{"Pending task"})
m.SetQueuedMessages("Pending task")
linesWithQueue := m.renderSections(35)
outputWithQueue := strings.Join(linesWithQueue, "\n")
assert.Contains(t, outputWithQueue, "Queue (1)")
Expand Down
10 changes: 5 additions & 5 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import (
"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/session"
"github.com/docker/cagent/pkg/tools"
chatmsgs "github.com/docker/cagent/pkg/tui/components/messages"
"github.com/docker/cagent/pkg/tui/components/scrollbar"
"github.com/docker/cagent/pkg/tui/components/spinner"
"github.com/docker/cagent/pkg/tui/components/tab"
"github.com/docker/cagent/pkg/tui/components/tool/todotool"
"github.com/docker/cagent/pkg/tui/components/toolcommon"
"github.com/docker/cagent/pkg/tui/core/layout"
"github.com/docker/cagent/pkg/tui/messages"
"github.com/docker/cagent/pkg/tui/service"
"github.com/docker/cagent/pkg/tui/styles"
)
Expand All @@ -49,7 +49,7 @@ type Model interface {
SetAgentSwitching(switching bool)
SetToolsetInfo(availableTools int, loading bool)
SetSessionStarred(starred bool)
SetQueuedMessages(messages []string)
SetQueuedMessages(messages ...string)
GetSize() (width, height int)
LoadFromSession(sess *session.Session)
// HandleClick checks if click is on the star and returns true if handled
Expand Down Expand Up @@ -192,8 +192,8 @@ func (m *model) SetSessionStarred(starred bool) {
}

// SetQueuedMessages sets the list of queued message previews to display
func (m *model) SetQueuedMessages(messages []string) {
m.queuedMessages = messages
func (m *model) SetQueuedMessages(queuedMessages ...string) {
m.queuedMessages = queuedMessages
}

// HandleClick checks if click is on the star and returns true if it was
Expand Down Expand Up @@ -382,7 +382,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
return m, m.spinner.Init()
}
return m, nil
case chatmsgs.StreamCancelledMsg:
case messages.StreamCancelledMsg:
// Clear all spinner-driving state when stream is cancelled via ESC
m.streamCancelled = true
m.workingAgent = ""
Expand Down
49 changes: 46 additions & 3 deletions pkg/tui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import (
"github.com/docker/cagent/pkg/browser"
"github.com/docker/cagent/pkg/evaluation"
"github.com/docker/cagent/pkg/modelsdev"
"github.com/docker/cagent/pkg/tools"
mcptools "github.com/docker/cagent/pkg/tools/mcp"
"github.com/docker/cagent/pkg/tui/components/editor"
"github.com/docker/cagent/pkg/tui/components/notification"
"github.com/docker/cagent/pkg/tui/core"
"github.com/docker/cagent/pkg/tui/dialog"
Expand Down Expand Up @@ -181,6 +181,39 @@ func (a *appModel) handleSwitchAgent(agentName string) (tea.Model, tea.Cmd) {
return a, notification.SuccessCmd(fmt.Sprintf("Switched to agent '%s'", agentName))
}

func (a *appModel) handleCycleAgent() (tea.Model, tea.Cmd) {
availableAgents := a.sessionState.AvailableAgents()
if len(availableAgents) <= 1 {
return a, notification.InfoCmd("No other agents available")
}

// Find the current agent index
currentIndex := -1
for i, agent := range availableAgents {
if agent.Name == a.sessionState.CurrentAgentName() {
currentIndex = i
break
}
}

// Cycle to the next agent (wrap around to 0 if at the end)
nextIndex := (currentIndex + 1) % len(availableAgents)
return a.handleSwitchToAgentByIndex(nextIndex)
}

func (a *appModel) handleSwitchToAgentByIndex(index int) (tea.Model, tea.Cmd) {
availableAgents := a.sessionState.AvailableAgents()
if index >= 0 && index < len(availableAgents) {
agentName := availableAgents[index].Name
if agentName != a.sessionState.CurrentAgentName() {
return a, core.CmdHandler(messages.SwitchAgentMsg{AgentName: agentName})
}
}
return a, nil
}

// Toggles

func (a *appModel) handleToggleYolo() (tea.Model, tea.Cmd) {
sess := a.application.Session()
sess.ToolsApproved = !sess.ToolsApproved
Expand Down Expand Up @@ -221,6 +254,8 @@ func (a *appModel) handleToggleHideToolResults() (tea.Model, tea.Cmd) {
return a, cmd
}

// Cost

func (a *appModel) handleShowCostDialog() (tea.Model, tea.Cmd) {
sess := a.application.Session()
return a, core.CmdHandler(dialog.OpenDialogMsg{
Expand All @@ -247,7 +282,7 @@ func (a *appModel) handleMCPPrompt(promptName string, arguments map[string]strin
return a, notification.ErrorCmd(fmt.Sprintf("Error executing MCP prompt '%s': %v", promptName, err))
}

return a, core.CmdHandler(editor.SendMsg{Content: promptContent})
return a, core.CmdHandler(messages.SendMsg{Content: promptContent})
}

// Miscellaneous handlers
Expand All @@ -259,7 +294,7 @@ func (a *appModel) handleOpenURL(url string) (tea.Model, tea.Cmd) {

func (a *appModel) handleAgentCommand(command string) (tea.Model, tea.Cmd) {
resolvedCommand := a.application.ResolveCommand(context.Background(), command)
return a, core.CmdHandler(editor.SendMsg{Content: resolvedCommand})
return a, core.CmdHandler(messages.SendMsg{Content: resolvedCommand})
}

// File attachment handler
Expand Down Expand Up @@ -375,3 +410,11 @@ func (a *appModel) handleSpeakTranscript(delta string) (tea.Model, tea.Cmd) {
a.chatPage.InsertText(delta + " ")
return a, nil
}

func (a *appModel) handleElicitationResponse(action tools.ElicitationAction, content map[string]any) (tea.Model, tea.Cmd) {
if err := a.application.ResumeElicitation(context.Background(), action, content); err != nil {
slog.Error("Failed to resume elicitation", "action", action, "error", err)
return a, notification.ErrorCmd("Failed to complete server request: " + err.Error())
}
return a, nil
}
Loading