diff --git a/pkg/app/app.go b/pkg/app/app.go index 25309c5a6..546282be2 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -192,6 +192,11 @@ func (a *App) Session() *session.Session { return a.session } +// SwitchAgent switches the currently active agent for subsequent user messages +func (a *App) SwitchAgent(agentName string) error { + return a.runtime.SetCurrentAgent(agentName) +} + func (a *App) CompactSession() { if a.session != nil { events := make(chan runtime.Event, 100) diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go index 7772e66c8..35da3d306 100644 --- a/pkg/runtime/event.go +++ b/pkg/runtime/event.go @@ -367,15 +367,23 @@ func AgentInfo(agentName, model, description, welcomeMessage string) Event { } } +// AgentDetails contains information about an agent for display in the sidebar +type AgentDetails struct { + Name string `json:"name"` + Description string `json:"description"` + Provider string `json:"provider"` + Model string `json:"model"` +} + // TeamInfoEvent is sent when team information is available type TeamInfoEvent struct { - Type string `json:"type"` - AvailableAgents []string `json:"available_agents"` - CurrentAgent string `json:"current_agent"` + Type string `json:"type"` + AvailableAgents []AgentDetails `json:"available_agents"` + CurrentAgent string `json:"current_agent"` AgentContext } -func TeamInfo(availableAgents []string, currentAgent string) Event { +func TeamInfo(availableAgents []AgentDetails, currentAgent string) Event { return &TeamInfoEvent{ Type: "team_info", AvailableAgents: availableAgents, diff --git a/pkg/runtime/remote_runtime.go b/pkg/runtime/remote_runtime.go index ceed894a4..631e18431 100644 --- a/pkg/runtime/remote_runtime.go +++ b/pkg/runtime/remote_runtime.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "strings" "time" "golang.org/x/oauth2" @@ -70,6 +71,15 @@ func (r *RemoteRuntime) CurrentAgentName() string { return r.currentAgent } +// SetCurrentAgent sets the currently active agent for subsequent user messages +func (r *RemoteRuntime) SetCurrentAgent(agentName string) error { + // For remote runtime, we trust the server to validate the agent name + // The actual validation happens when RunStream is called + r.currentAgent = agentName + slog.Debug("Switched current agent (remote)", "agent", agentName) + return nil +} + func (r *RemoteRuntime) CurrentAgentCommands(ctx context.Context) map[string]string { return r.readCurrentAgentConfig(ctx).Commands } @@ -79,10 +89,34 @@ func (r *RemoteRuntime) EmitStartupInfo(ctx context.Context, events chan Event) cfg := r.readCurrentAgentConfig(ctx) events <- AgentInfo(r.currentAgent, cfg.Model, cfg.Description, cfg.WelcomeMessage) - events <- TeamInfo(r.team.AgentNames(), r.currentAgent) + events <- TeamInfo(r.agentDetailsFromConfig(ctx), r.currentAgent) events <- ToolsetInfo(len(cfg.Toolsets), r.currentAgent) } +// agentDetailsFromConfig builds AgentDetails from remote config +func (r *RemoteRuntime) agentDetailsFromConfig(ctx context.Context) []AgentDetails { + cfg, err := r.client.GetAgent(ctx, r.agentFilename) + if err != nil { + return nil + } + + var details []AgentDetails + for name, agentCfg := range cfg.Agents { + info := AgentDetails{ + Name: name, + Description: agentCfg.Description, + } + if provider, model, found := strings.Cut(agentCfg.Model, "/"); found { + info.Provider = provider + info.Model = model + } else { + info.Model = agentCfg.Model + } + details = append(details, info) + } + return details +} + func (r *RemoteRuntime) readCurrentAgentConfig(ctx context.Context) latest.AgentConfig { cfg, err := r.client.GetAgent(ctx, r.agentFilename) if err != nil { diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index b5c6a0752..1a122b732 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -94,6 +94,8 @@ type ElicitationRequestHandler func(ctx context.Context, message string, schema type Runtime interface { // CurrentAgentName returns the name of the currently active agent CurrentAgentName() string + // SetCurrentAgent sets the currently active agent for subsequent user messages + SetCurrentAgent(agentName string) error // CurrentAgentCommands returns the commands for the active agent CurrentAgentCommands(ctx context.Context) map[string]string // EmitStartupInfo emits initial agent, team, and toolset information for immediate display @@ -337,6 +339,16 @@ func (r *LocalRuntime) CurrentAgentName() string { return r.currentAgent } +func (r *LocalRuntime) SetCurrentAgent(agentName string) error { + // Validate that the agent exists in the team + if _, err := r.team.Agent(agentName); err != nil { + return err + } + r.currentAgent = agentName + slog.Debug("Switched current agent", "agent", agentName) + return nil +} + func (r *LocalRuntime) CurrentAgentCommands(context.Context) map[string]string { return r.CurrentAgent().Commands() } @@ -421,6 +433,21 @@ func getAgentModelID(a *agent.Agent) string { return "" } +// agentDetailsFromTeam converts team agent info to AgentDetails for events +func (r *LocalRuntime) agentDetailsFromTeam() []AgentDetails { + agentsInfo := r.team.AgentsInfo() + details := make([]AgentDetails, len(agentsInfo)) + for i, info := range agentsInfo { + details[i] = AgentDetails{ + Name: info.Name, + Description: info.Description, + Provider: info.Provider, + Model: info.Model, + } + } + return details +} + // EmitStartupInfo emits initial agent, team, and toolset information for immediate sidebar display func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, events chan Event) { // Prevent duplicate emissions @@ -432,7 +459,7 @@ func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, events chan Event) { // Emit agent information for sidebar display events <- AgentInfo(a.Name(), getAgentModelID(a), a.Description(), a.WelcomeMessage()) - events <- TeamInfo(r.team.AgentNames(), r.currentAgent) + events <- TeamInfo(r.agentDetailsFromTeam(), r.currentAgent) // Emit agent warnings (if any) r.emitAgentWarnings(a, events) @@ -511,8 +538,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c events <- AgentInfo(a.Name(), getAgentModelID(a), a.Description(), a.WelcomeMessage()) // Emit team information - availableAgents := r.team.AgentNames() - events <- TeamInfo(availableAgents, r.currentAgent) + events <- TeamInfo(r.agentDetailsFromTeam(), r.currentAgent) // Initialize RAG and forward events r.InitializeRAG(ctx, events) diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 0300f8fbb..47b3d856c 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -218,7 +218,7 @@ func TestSimple(t *testing.T) { expectedEvents := []Event{ AgentInfo("root", "test/mock-model", "", ""), - TeamInfo([]string{"root"}, "root"), + TeamInfo([]AgentDetails{{Name: "root", Provider: "test", Model: "mock-model"}}, "root"), ToolsetInfo(0, "root"), UserMessage("Hi"), StreamStarted(sess.ID, "root"), @@ -246,7 +246,7 @@ func TestMultipleContentChunks(t *testing.T) { expectedEvents := []Event{ AgentInfo("root", "test/mock-model", "", ""), - TeamInfo([]string{"root"}, "root"), + TeamInfo([]AgentDetails{{Name: "root", Provider: "test", Model: "mock-model"}}, "root"), ToolsetInfo(0, "root"), UserMessage("Please greet me"), StreamStarted(sess.ID, "root"), @@ -276,7 +276,7 @@ func TestWithReasoning(t *testing.T) { expectedEvents := []Event{ AgentInfo("root", "test/mock-model", "", ""), - TeamInfo([]string{"root"}, "root"), + TeamInfo([]AgentDetails{{Name: "root", Provider: "test", Model: "mock-model"}}, "root"), ToolsetInfo(0, "root"), UserMessage("Hi"), StreamStarted(sess.ID, "root"), @@ -305,7 +305,7 @@ func TestMixedContentAndReasoning(t *testing.T) { expectedEvents := []Event{ AgentInfo("root", "test/mock-model", "", ""), - TeamInfo([]string{"root"}, "root"), + TeamInfo([]AgentDetails{{Name: "root", Provider: "test", Model: "mock-model"}}, "root"), ToolsetInfo(0, "root"), UserMessage("Hi there"), StreamStarted(sess.ID, "root"), @@ -840,7 +840,10 @@ func TestEmitStartupInfo(t *testing.T) { // Verify expected events are emitted expectedEvents := []Event{ AgentInfo("startup-test-agent", "test/startup-model", "This is a startup test agent", "Welcome!"), - TeamInfo([]string{"other-agent", "startup-test-agent"}, "startup-test-agent"), + TeamInfo([]AgentDetails{ + {Name: "other-agent", Description: "This is another agent", Provider: "test", Model: "startup-model"}, + {Name: "startup-test-agent", Description: "This is a startup test agent", Provider: "test", Model: "startup-model"}, + }, "startup-test-agent"), ToolsetInfo(0, "startup-test-agent"), // No tools configured } diff --git a/pkg/team/team.go b/pkg/team/team.go index 64ee79708..018c59dca 100644 --- a/pkg/team/team.go +++ b/pkg/team/team.go @@ -54,6 +54,37 @@ func (t *Team) AgentNames() []string { return names } +// AgentInfo contains information about an agent +type AgentInfo struct { + Name string + Description string + Provider string + Model string +} + +// AgentsInfo returns information about all agents in the team +func (t *Team) AgentsInfo() []AgentInfo { + var infos []AgentInfo + for _, name := range t.AgentNames() { + a := t.agents[name] + info := AgentInfo{ + Name: name, + Description: a.Description(), + } + if model := a.Model(); model != nil { + modelID := model.ID() + if prov, modelName, found := strings.Cut(modelID, "/"); found { + info.Provider = prov + info.Model = modelName + } else { + info.Model = modelID + } + } + infos = append(infos, info) + } + return infos +} + func (t *Team) Agent(name string) (*agent.Agent, error) { if t.Size() == 0 { return nil, errors.New("no agents loaded; ensure your agent configuration defines at least one agent") diff --git a/pkg/tui/components/notification/notification.go b/pkg/tui/components/notification/notification.go index 8486dc369..e24b91a7e 100644 --- a/pkg/tui/components/notification/notification.go +++ b/pkg/tui/components/notification/notification.go @@ -53,6 +53,13 @@ func WarningCmd(text string) tea.Cmd { }) } +func InfoCmd(text string) tea.Cmd { + return core.CmdHandler(ShowMsg{ + Text: text, + Type: TypeInfo, + }) +} + func ErrorCmd(text string) tea.Cmd { return core.CmdHandler(ShowMsg{ Text: text, diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 34f418eee..07f414603 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -16,7 +16,6 @@ import ( "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/service" "github.com/docker/cagent/pkg/tui/styles" @@ -38,7 +37,7 @@ type Model interface { SetTodos(result *tools.ToolCallResult) error SetMode(mode Mode) SetAgentInfo(agentName, model, description string) - SetTeamInfo(availableAgents []string) + SetTeamInfo(availableAgents []runtime.AgentDetails) SetAgentSwitching(switching bool) SetToolsetInfo(availableTools int) GetSize() (width, height int) @@ -66,7 +65,7 @@ type model struct { currentAgent string agentModel string agentDescription string - availableAgents []string + availableAgents []runtime.AgentDetails agentSwitching bool availableTools int sessionState *service.SessionState @@ -113,7 +112,7 @@ func (m *model) SetAgentInfo(agentName, model, description string) { } // SetTeamInfo sets the available agents in the team -func (m *model) SetTeamInfo(availableAgents []string) { +func (m *model) SetTeamInfo(availableAgents []runtime.AgentDetails) { m.availableAgents = availableAgents } @@ -498,46 +497,69 @@ func (m *model) sessionInfo() string { // agentInfo renders the current agent information func (m *model) agentInfo() string { - if m.currentAgent == "" { + // Read current agent from session state so sidebar updates when agent is switched + currentAgent := m.sessionState.CurrentAgent + if currentAgent == "" { return "" } - // Agent name with highlight and switching indicator agentTitle := "Agent" if m.agentSwitching { - agentTitle += " ↔" // switching indicator - } - - // Current agent name - agentName := m.currentAgent - if m.agentSwitching { - agentName = "⟳ " + agentName // switching icon + agentTitle += " ↔" } var content strings.Builder - content.WriteString(styles.TabAccentStyle.Render(agentName)) + for i, agent := range m.availableAgents { + if content.Len() > 0 { + content.WriteString("\n\n") + } + isCurrent := agent.Name == currentAgent + m.renderAgentEntry(&content, agent, isCurrent, i) + } - // Agent description if available - if m.agentDescription != "" { - maxDescWidth := m.width - 2 - description := toolcommon.TruncateText(m.agentDescription, maxDescWidth) + return m.renderTab(agentTitle, content.String()) +} - fmt.Fprintf(&content, "\n%s", description) +func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDetails, isCurrent bool, index int) { + prefix := "" + if isCurrent { + prefix = "▶ " } - - // Team info if multiple agents available - if len(m.availableAgents) > 1 { - fmt.Fprintf(&content, "\nTeam: %d agents", len(m.availableAgents)) + // Agent name + agentNameText := styles.TabAccentStyle.Render(prefix + agent.Name) + // Shortcut hint (^1, ^2, etc.) - show for agents 1-9 + var shortcutHint string + if index >= 0 && index < 9 { + shortcutHint = styles.MutedStyle.Render(fmt.Sprintf("^%d", index+1)) + } + // Calculate space needed to right-align the shortcut + nameWidth := lipgloss.Width(agentNameText) + hintWidth := lipgloss.Width(shortcutHint) + spaceWidth := m.width - nameWidth - hintWidth - 2 // -2 for padding + if spaceWidth < 1 { + spaceWidth = 1 + } + if shortcutHint != "" { + content.WriteString(agentNameText + strings.Repeat(" ", spaceWidth) + shortcutHint) + } else { + content.WriteString(agentNameText) } - // Model info if available - if m.agentModel != "" { - provider, model, _ := strings.Cut(m.agentModel, "/") - fmt.Fprintf(&content, "\nProvider: %s", provider) - fmt.Fprintf(&content, "\nModel: %s", model) + if desc := agent.Description; desc != "" { + maxDescWidth := m.width - 2 + if lipgloss.Width(desc) > maxDescWidth { + runes := []rune(desc) + for lipgloss.Width(string(runes)) > maxDescWidth-1 && len(runes) > 0 { + runes = runes[:len(runes)-1] + } + desc = string(runes) + "…" + } + content.WriteString("\n") + content.WriteString(desc) } - return m.renderTab(agentTitle, content.String()) + content.WriteString("\nProvider: " + styles.MutedStyle.Render(agent.Provider)) + content.WriteString("\nModel: " + styles.MutedStyle.Render(agent.Model)) } // toolsetInfo renders the current toolset status information diff --git a/pkg/tui/messages/messages.go b/pkg/tui/messages/messages.go index eae8ecba7..d5d8935f6 100644 --- a/pkg/tui/messages/messages.go +++ b/pkg/tui/messages/messages.go @@ -8,6 +8,7 @@ type ( CopySessionToClipboardMsg struct{} ToggleYoloMsg struct{} StartShellMsg struct{} + SwitchAgentMsg struct{ AgentName string } // Switch to a specific agent by name ) // AgentCommandMsg command message diff --git a/pkg/tui/service/sessionstate.go b/pkg/tui/service/sessionstate.go index e5b44fc06..3fbda73aa 100644 --- a/pkg/tui/service/sessionstate.go +++ b/pkg/tui/service/sessionstate.go @@ -14,6 +14,8 @@ type SessionState struct { SplitDiffView bool YoloMode bool PreviousMessage *types.Message + // CurrentAgent is the name of the currently active agent for user messages + CurrentAgent string } // NewSessionState creates a new SessionState with default values. @@ -32,3 +34,7 @@ func (s *SessionState) ToggleSplitDiffView() { func (s *SessionState) SetYoloMode(enabled bool) { s.YoloMode = enabled } + +func (s *SessionState) SetCurrentAgent(agentName string) { + s.CurrentAgent = agentName +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 3d9a07902..72f4123ec 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -47,6 +47,10 @@ type appModel struct { // Session state sessionState *service.SessionState + // Agent state + availableAgents []runtime.AgentDetails + currentAgent string + // State ready bool err error @@ -57,7 +61,7 @@ type KeyMap struct { Quit key.Binding CommandPalette key.Binding ToggleYolo key.Binding - StartShell key.Binding + SwitchAgent key.Binding } // DefaultKeyMap returns the default global key bindings @@ -75,9 +79,9 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+y"), key.WithHelp("Ctrl+y", "toggle yolo mode"), ), - StartShell: key.NewBinding( + SwitchAgent: key.NewBinding( key.WithKeys("ctrl+s"), - key.WithHelp("Ctrl+s", "start a shell"), + key.WithHelp("Ctrl+s", "cycle agent"), ), } } @@ -175,6 +179,16 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case StartupEventsMsg: var cmds []tea.Cmd for _, event := range msg.Events { + // Track team and agent info for agent switching + switch ev := event.(type) { + case *runtime.TeamInfoEvent: + a.availableAgents = ev.AvailableAgents + a.currentAgent = ev.CurrentAgent + a.sessionState.SetCurrentAgent(ev.CurrentAgent) + case *runtime.AgentInfoEvent: + a.currentAgent = ev.AgentName + a.sessionState.SetCurrentAgent(ev.AgentName) + } updated, cmd := a.chatPage.Update(event) a.chatPage = updated.(chat.Page) if cmd != nil { @@ -183,6 +197,35 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) + case *runtime.TeamInfoEvent: + // Store team info for agent switching shortcuts + a.availableAgents = msg.AvailableAgents + a.currentAgent = msg.CurrentAgent + a.sessionState.SetCurrentAgent(msg.CurrentAgent) + // Forward to chat page + updated, cmd := a.chatPage.Update(msg) + a.chatPage = updated.(chat.Page) + return a, cmd + + case *runtime.AgentInfoEvent: + // Track current agent + a.currentAgent = msg.AgentName + a.sessionState.SetCurrentAgent(msg.AgentName) + // Forward to chat page + updated, cmd := a.chatPage.Update(msg) + a.chatPage = updated.(chat.Page) + return a, cmd + + case messages.SwitchAgentMsg: + // Switch the agent in the runtime + if err := a.application.SwitchAgent(msg.AgentName); err != nil { + return a, notification.ErrorCmd(fmt.Sprintf("Failed to switch to agent '%s': %v", msg.AgentName, err)) + } + // Update local tracking + a.currentAgent = msg.AgentName + a.sessionState.SetCurrentAgent(msg.AgentName) + return a, notification.SuccessCmd(fmt.Sprintf("Switched to agent '%s'", msg.AgentName)) + case tea.WindowSizeMsg: a.wWidth, a.wHeight = msg.Width, msg.Height cmd := a.handleWindowResize(msg.Width, msg.Height) @@ -406,16 +449,61 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, a.keyMap.ToggleYolo): return a, core.CmdHandler(messages.ToggleYoloMsg{}) - case key.Matches(msg, a.keyMap.StartShell): - return a, core.CmdHandler(messages.StartShellMsg{}) + case key.Matches(msg, a.keyMap.SwitchAgent): + // Cycle to the next agent in the list + return a.cycleToNextAgent() default: + // Handle ctrl+1 through ctrl+9 for quick agent switching + if index := parseCtrlNumberKey(msg); index >= 0 { + return a.switchToAgentByIndex(index) + } updated, cmd := a.chatPage.Update(msg) a.chatPage = updated.(chat.Page) return a, cmd } } +// parseCtrlNumberKey checks if msg is ctrl+1 through ctrl+9 and returns the index (0-8), or -1 if not matched +func parseCtrlNumberKey(msg tea.KeyPressMsg) int { + s := msg.String() + if len(s) == 6 && s[:5] == "ctrl+" && s[5] >= '1' && s[5] <= '9' { + return int(s[5] - '1') + } + return -1 +} + +// switchToAgentByIndex switches to the agent at the given index +func (a *appModel) switchToAgentByIndex(index int) (tea.Model, tea.Cmd) { + if index >= 0 && index < len(a.availableAgents) { + agentName := a.availableAgents[index].Name + if agentName != a.currentAgent { + 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) { + if len(a.availableAgents) <= 1 { + return a, notification.InfoCmd("No other agents available") + } + + // Find the current agent index + currentIndex := -1 + for i, agent := range a.availableAgents { + if agent.Name == a.currentAgent { + currentIndex = i + break + } + } + + // Cycle to the next agent (wrap around to 0 if at the end) + nextIndex := (currentIndex + 1) % len(a.availableAgents) + return a.switchToAgentByIndex(nextIndex) +} + // View renders the complete application interface func (a *appModel) View() tea.View { // Show error if present