diff --git a/pkg/app/app.go b/pkg/app/app.go index 9e3b2f111..e84adc563 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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 { @@ -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 @@ -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. diff --git a/pkg/app/testdata/assistant_message.golden b/pkg/app/transcript/testdata/assistant_message.golden similarity index 100% rename from pkg/app/testdata/assistant_message.golden rename to pkg/app/transcript/testdata/assistant_message.golden diff --git a/pkg/app/testdata/assistant_message_with_reasoning.golden b/pkg/app/transcript/testdata/assistant_message_with_reasoning.golden similarity index 100% rename from pkg/app/testdata/assistant_message_with_reasoning.golden rename to pkg/app/transcript/testdata/assistant_message_with_reasoning.golden diff --git a/pkg/app/testdata/simple.golden b/pkg/app/transcript/testdata/simple.golden similarity index 100% rename from pkg/app/testdata/simple.golden rename to pkg/app/transcript/testdata/simple.golden diff --git a/pkg/app/testdata/tool_calls.golden b/pkg/app/transcript/testdata/tool_calls.golden similarity index 100% rename from pkg/app/testdata/tool_calls.golden rename to pkg/app/transcript/testdata/tool_calls.golden diff --git a/pkg/app/transcript.go b/pkg/app/transcript/transcript.go similarity index 97% rename from pkg/app/transcript.go rename to pkg/app/transcript/transcript.go index 41848491b..c1e65713c 100644 --- a/pkg/app/transcript.go +++ b/pkg/app/transcript/transcript.go @@ -1,4 +1,4 @@ -package app +package transcript import ( "encoding/json" @@ -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() diff --git a/pkg/app/transcript_test.go b/pkg/app/transcript/transcript_test.go similarity index 92% rename from pkg/app/transcript_test.go rename to pkg/app/transcript/transcript_test.go index 22092e404..bd738ab2b 100644 --- a/pkg/app/transcript_test.go +++ b/pkg/app/transcript/transcript_test.go @@ -1,4 +1,4 @@ -package app +package transcript import ( "testing" @@ -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") } @@ -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") } @@ -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") } @@ -71,7 +71,7 @@ func TestToolCalls(t *testing.T) { Content: ".\n..", }, }) - content := transcript(sess) + content := PlainText(sess) golden.Assert(t, content, "tool_calls.golden") } diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index f3995660f..d0a17b0f2 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -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" ) @@ -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 @@ -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 diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 835350170..aa9dfd414 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -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{} @@ -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 @@ -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 { @@ -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() } diff --git a/pkg/tui/components/sidebar/queue_test.go b/pkg/tui/components/sidebar/queue_test.go index 929b47f59..701128b5b 100644 --- a/pkg/tui/components/sidebar/queue_test.go +++ b/pkg/tui/components/sidebar/queue_test.go @@ -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) @@ -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) @@ -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 @@ -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)") diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 3553aad3c..c9ee159dc 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -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" ) @@ -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 @@ -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 @@ -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 = "" diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index aa75a4e40..02a4e0f6f 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -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" @@ -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 @@ -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{ @@ -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 @@ -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 @@ -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 +} diff --git a/pkg/tui/messages/messages.go b/pkg/tui/messages/messages.go index 5fff540fa..0f2c2322d 100644 --- a/pkg/tui/messages/messages.go +++ b/pkg/tui/messages/messages.go @@ -1,6 +1,9 @@ package messages -import "github.com/docker/cagent/pkg/tools" +import ( + "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/tools" +) // Session command messages type ( @@ -19,40 +22,37 @@ type ( SwitchAgentMsg struct{ AgentName string } OpenSessionBrowserMsg struct{} LoadSessionMsg struct{ SessionID string } - ToggleSessionStarMsg struct{ SessionID string } // Toggle star on a session; empty ID means current session - AttachFileMsg struct{ FilePath string } // Attach a file directly or open file picker if empty/directory - InsertFileRefMsg struct{ FilePath string } // Insert @filepath reference into editor - OpenModelPickerMsg struct{} // Open the model picker dialog - ChangeModelMsg struct{ ModelRef string } // Change the model for the current agent - StartSpeakMsg struct{} // Start speech-to-text transcription - StopSpeakMsg struct{} // Stop speech-to-text transcription - SpeakTranscriptMsg struct{ Delta string } // Transcription delta from speech-to-text - ClearQueueMsg struct{} // Clear all queued messages -) + ToggleSessionStarMsg struct{ SessionID string } // Toggle star on a session; empty ID means current session + AttachFileMsg struct{ FilePath string } // Attach a file directly or open file picker if empty/directory + InsertFileRefMsg struct{ FilePath string } // Insert @filepath reference into editor + OpenModelPickerMsg struct{} // Open the model picker dialog + ChangeModelMsg struct{ ModelRef string } // Change the model for the current agent + StartSpeakMsg struct{} // Start speech-to-text transcription + StopSpeakMsg struct{} // Stop speech-to-text transcription + SpeakTranscriptMsg struct{ Delta string } // Transcription delta from speech-to-text + ClearQueueMsg struct{} // Clear all queued messages + AgentCommandMsg struct{ Command string } // AgentCommandMsg command message + OpenURLMsg struct{ URL string } // OpenURLMsg is a url for opening message + StreamCancelledMsg struct{ ShowMessage bool } // StreamCancelledMsg notifies components that the stream has been cancelled + SendAttachmentMsg struct{ Content *session.Message } // Message for the first message with an attachment -// AgentCommandMsg command message -type AgentCommandMsg struct { - Command string -} + MCPPromptMsg struct { + PromptName string + Arguments map[string]string + } -// MCPPromptMsg command message -type MCPPromptMsg struct { - PromptName string - Arguments map[string]string -} + ShowMCPPromptInputMsg struct { + PromptName string + PromptInfo any // mcptools.PromptInfo but avoiding import cycles + } -// OpenURLMsg is a url for opening message -type OpenURLMsg struct { - URL string -} + ElicitationResponseMsg struct { + Action tools.ElicitationAction + Content map[string]any + } -type ShowMCPPromptInputMsg struct { - PromptName string - PromptInfo any // mcptools.PromptInfo but avoiding import cycles -} - -// ElicitationResponseMsg is sent when the user responds to an elicitation dialog -type ElicitationResponseMsg struct { - Action tools.ElicitationAction - Content map[string]any -} + 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 + } +) diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index 921a71c5e..65eeea8d6 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -320,11 +320,7 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case tea.MouseWheelMsg: return p.handleMouseWheel(msg) - case editor.SendMsg: - slog.Debug(msg.Content) - return p.handleSendMsg(msg) - - case messages.StreamCancelledMsg: + case msgtypes.StreamCancelledMsg: model, cmd := p.messages.Update(msg) p.messages = model.(messages.Model) @@ -347,6 +343,10 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return p, tea.Batch(cmds...) + case msgtypes.SendMsg: + slog.Debug(msg.Content) + return p.handleSendMsg(msg) + case msgtypes.InsertFileRefMsg: // Attach file using editor's AttachFile method which registers the attachment p.editor.AttachFile(msg.FilePath) @@ -602,14 +602,14 @@ func (p *chatPage) cancelStream(showCancelMessage bool) tea.Cmd { // Send StreamCancelledMsg to all components to handle cleanup return tea.Batch( - core.CmdHandler(messages.StreamCancelledMsg{ShowMessage: showCancelMessage}), + core.CmdHandler(msgtypes.StreamCancelledMsg{ShowMessage: showCancelMessage}), p.setWorking(false), ) } // handleSendMsg handles incoming messages from the editor, either processing // them immediately or queuing them if the agent is busy. -func (p *chatPage) handleSendMsg(msg editor.SendMsg) (layout.Model, tea.Cmd) { +func (p *chatPage) handleSendMsg(msg msgtypes.SendMsg) (layout.Model, tea.Cmd) { // If not working, process immediately if !p.working { cmd := p.processMessage(msg) @@ -647,7 +647,7 @@ func (p *chatPage) processNextQueuedMessage() tea.Cmd { p.messageQueue = p.messageQueue[1:] p.syncQueueToSidebar() - msg := editor.SendMsg{ + msg := msgtypes.SendMsg{ Content: queued.content, Attachments: queued.attachments, } @@ -685,11 +685,11 @@ func (p *chatPage) syncQueueToSidebar() { } previews[i] = content } - p.sidebar.SetQueuedMessages(previews) + p.sidebar.SetQueuedMessages(previews...) } // processMessage processes a message with the runtime -func (p *chatPage) processMessage(msg editor.SendMsg) tea.Cmd { +func (p *chatPage) processMessage(msg msgtypes.SendMsg) tea.Cmd { if p.msgCancel != nil { p.msgCancel() } diff --git a/pkg/tui/page/chat/queue_test.go b/pkg/tui/page/chat/queue_test.go index db3e9ae17..36d67613d 100644 --- a/pkg/tui/page/chat/queue_test.go +++ b/pkg/tui/page/chat/queue_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/docker/cagent/pkg/tui/components/editor" "github.com/docker/cagent/pkg/tui/components/sidebar" + "github.com/docker/cagent/pkg/tui/messages" "github.com/docker/cagent/pkg/tui/service" ) @@ -32,7 +32,7 @@ func TestQueueFlow_BusyAgent_QueuesMessage(t *testing.T) { // newTestChatPage already sets working=true // Send first message while busy - msg1 := editor.SendMsg{Content: "first message"} + msg1 := messages.SendMsg{Content: "first message"} _, cmd := p.handleSendMsg(msg1) // Should be queued @@ -42,7 +42,7 @@ func TestQueueFlow_BusyAgent_QueuesMessage(t *testing.T) { assert.NotNil(t, cmd) // Send second message while still busy - msg2 := editor.SendMsg{Content: "second message"} + msg2 := messages.SendMsg{Content: "second message"} _, _ = p.handleSendMsg(msg2) require.Len(t, p.messageQueue, 2) @@ -50,7 +50,7 @@ func TestQueueFlow_BusyAgent_QueuesMessage(t *testing.T) { assert.Equal(t, "second message", p.messageQueue[1].content) // Send third message - msg3 := editor.SendMsg{Content: "third message"} + msg3 := messages.SendMsg{Content: "third message"} _, _ = p.handleSendMsg(msg3) require.Len(t, p.messageQueue, 3) @@ -64,7 +64,7 @@ func TestQueueFlow_QueueFull_RejectsMessage(t *testing.T) { // Fill the queue to max for i := range maxQueuedMessages { - msg := editor.SendMsg{Content: "message"} + msg := messages.SendMsg{Content: "message"} _, _ = p.handleSendMsg(msg) assert.Len(t, p.messageQueue, i+1) } @@ -72,7 +72,7 @@ func TestQueueFlow_QueueFull_RejectsMessage(t *testing.T) { require.Len(t, p.messageQueue, maxQueuedMessages) // Try to add one more - should be rejected - msg := editor.SendMsg{Content: "overflow message"} + msg := messages.SendMsg{Content: "overflow message"} _, cmd := p.handleSendMsg(msg) // Queue size should not change @@ -87,9 +87,9 @@ func TestQueueFlow_PopFromQueue(t *testing.T) { p := newTestChatPage(t) // Queue some messages - p.handleSendMsg(editor.SendMsg{Content: "first"}) - p.handleSendMsg(editor.SendMsg{Content: "second"}) - p.handleSendMsg(editor.SendMsg{Content: "third"}) + p.handleSendMsg(messages.SendMsg{Content: "first"}) + p.handleSendMsg(messages.SendMsg{Content: "second"}) + p.handleSendMsg(messages.SendMsg{Content: "third"}) require.Len(t, p.messageQueue, 3) @@ -127,9 +127,9 @@ func TestQueueFlow_ClearQueue(t *testing.T) { // newTestChatPage sets working=true // Queue some messages - p.handleSendMsg(editor.SendMsg{Content: "first"}) - p.handleSendMsg(editor.SendMsg{Content: "second"}) - p.handleSendMsg(editor.SendMsg{Content: "third"}) + p.handleSendMsg(messages.SendMsg{Content: "first"}) + p.handleSendMsg(messages.SendMsg{Content: "second"}) + p.handleSendMsg(messages.SendMsg{Content: "third"}) require.Len(t, p.messageQueue, 3) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 7a7fc2f08..3ee233d60 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -3,7 +3,6 @@ package tui import ( "cmp" "context" - "log/slog" "os" "os/exec" goruntime "runtime" @@ -15,12 +14,9 @@ import ( "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/audio/transcribe" - "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/runtime" - "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/tui/commands" "github.com/docker/cagent/pkg/tui/components/completion" - "github.com/docker/cagent/pkg/tui/components/editor" "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/statusbar" "github.com/docker/cagent/pkg/tui/core" @@ -59,7 +55,7 @@ type KeyMap struct { CommandPalette key.Binding ToggleYolo key.Binding ToggleHideToolResults key.Binding - SwitchAgent key.Binding + CycleAgent key.Binding ModelPicker key.Binding Speak key.Binding ClearQueue key.Binding @@ -84,7 +80,7 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+o"), key.WithHelp("Ctrl+o", "toggle tool output"), ), - SwitchAgent: key.NewBinding( + CycleAgent: key.NewBinding( key.WithKeys("ctrl+s"), key.WithHelp("Ctrl+s", "cycle agent"), ), @@ -131,35 +127,11 @@ func New(ctx context.Context, a *app.App) tea.Model { // Init initializes the application func (a *appModel) Init() tea.Cmd { - cmds := []tea.Cmd{ + return tea.Sequence( a.dialog.Init(), a.chatPage.Init(), - } - - if firstMessage := a.application.FirstMessage(); firstMessage != nil { - cmds = append(cmds, func() tea.Msg { - // Use the shared PrepareUserMessage function for consistent attachment handling - userMsg := cli.PrepareUserMessage(context.Background(), a.application.Runtime(), *firstMessage, a.application.FirstMessageAttachment()) - - // If the message has multi-content (attachments), we need to handle it specially - if len(userMsg.Message.MultiContent) > 0 { - return firstMessageWithAttachment{ - message: userMsg, - } - } - - return editor.SendMsg{ - Content: userMsg.Message.Content, - } - }) - } - - return tea.Batch(cmds...) -} - -// firstMessageWithAttachment is a message for the first message with an attachment -type firstMessageWithAttachment struct { - message *session.Message + a.application.SendFirstMessage(), + ) } // Help returns help information @@ -336,11 +308,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a.handleChangeModel(msg.ModelRef) case messages.ElicitationResponseMsg: - // Handle elicitation response from the dialog - if err := a.application.ResumeElicitation(context.Background(), msg.Action, msg.Content); err != nil { - slog.Error("Failed to resume elicitation", "action", msg.Action, "error", err) - return a, notification.ErrorCmd("Failed to complete server request: " + err.Error()) - } + return a.handleElicitationResponse(msg.Action, msg.Content) + + case messages.SendAttachmentMsg: + // Handle first message with image attachment using the pre-prepared message + a.application.RunWithMessage(context.Background(), nil, msg.Content) return a, nil case speakTranscriptAndContinue: @@ -362,11 +334,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.completions.SetEditorBottom(msg.Height) return a, nil - case firstMessageWithAttachment: - // Handle first message with image attachment using the pre-prepared message - a.application.RunWithMessage(context.Background(), nil, msg.message) - return a, nil - case error: a.err = msg return a, nil @@ -493,9 +460,8 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keyMap.ToggleHideToolResults): return a, core.CmdHandler(messages.ToggleHideToolResultsMsg{}) - case key.Matches(msg, a.keyMap.SwitchAgent): - // Cycle to the next agent in the list - return a.cycleToNextAgent() + case key.Matches(msg, a.keyMap.CycleAgent): + return a.handleCycleAgent() case key.Matches(msg, a.keyMap.ModelPicker): return a.handleOpenModelPicker() @@ -512,8 +478,9 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { default: // Handle ctrl+1 through ctrl+9 for quick agent switching if index := parseCtrlNumberKey(msg); index >= 0 { - return a.switchToAgentByIndex(index) + return a.handleSwitchToAgentByIndex(index) } + updated, cmd := a.chatPage.Update(msg) a.chatPage = updated.(chat.Page) return a, cmd @@ -529,39 +496,6 @@ func parseCtrlNumberKey(msg tea.KeyPressMsg) int { return -1 } -// switchToAgentByIndex switches to the agent at the given index -func (a *appModel) switchToAgentByIndex(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 -} - -// cycleToNextAgent cycles to the next agent in the available agents list -func (a *appModel) cycleToNextAgent() (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.switchToAgentByIndex(nextIndex) -} - // View renders the complete application interface func (a *appModel) View() tea.View { windowTitle := a.windowTitle()