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
5 changes: 5 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 12 additions & 4 deletions pkg/runtime/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 35 additions & 1 deletion pkg/runtime/remote_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"

"golang.org/x/oauth2"
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
32 changes: 29 additions & 3 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions pkg/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
}

Expand Down
31 changes: 31 additions & 0 deletions pkg/team/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions pkg/tui/components/notification/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
80 changes: 51 additions & 29 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading