From 5890f01053c0640cb094b534f90bf8f06eb891b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Mon, 15 Jun 2026 08:12:23 +0200 Subject: [PATCH 1/2] feat(runtime): expose a read-only snapshot of background-agent tasks The background-agent Handler keeps its tasks in an unexported concurrent map, so the TUI had no way to observe the genuinely-concurrent fleet spawned by run_background_agent. Expose a public, lock-safe read model without touching task lifecycle: - agent.Handler.Snapshot() returns []TaskInfo (id, agent, task text, status string, start time). It ranges the tasks map reading only fields fixed at creation plus the atomic status, so it is safe to call concurrently with running task goroutines and never leaks the internal *task. runCollecting's event-drop semantics are unchanged. - LocalRuntime.BackgroundAgents() surfaces the snapshot; the App reaches it through an optional interface so remote runtimes report none, like the other read-only runtime accessors. This is the seam the TUI's live background-agent surface polls. Refs #3103 --- pkg/app/app.go | 19 ++++++++ pkg/runtime/runtime.go | 12 +++++ pkg/tools/builtin/agent/agent.go | 50 +++++++++++++++++++-- pkg/tools/builtin/agent/agent_test.go | 64 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 8fa8f85fb..87024c7cc 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -29,6 +29,7 @@ import ( "github.com/docker/docker-agent/pkg/shellpath" "github.com/docker/docker-agent/pkg/skills" "github.com/docker/docker-agent/pkg/tools" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" skillstool "github.com/docker/docker-agent/pkg/tools/builtin/skills" mcptools "github.com/docker/docker-agent/pkg/tools/mcp" "github.com/docker/docker-agent/pkg/tui/messages" @@ -236,6 +237,24 @@ func (a *App) CurrentAgentSkills() []skills.Skill { return st.Skills() } +// backgroundAgentLister is implemented by runtimes that run background agents +// in-process (the local runtime). Remote/client runtimes don't, so the App +// reports no background agents for them. +type backgroundAgentLister interface { + BackgroundAgents() []agenttool.TaskInfo +} + +// BackgroundAgents returns a read-only snapshot of in-flight background agent +// tasks, or nil when the runtime doesn't run them in-process (e.g. remote +// runtimes). Mirrors the other read-only accessors that surface runtime state +// to the TUI, like CurrentAgentSkills. +func (a *App) BackgroundAgents() []agenttool.TaskInfo { + if l, ok := a.runtime.(backgroundAgentLister); ok { + return l.BackgroundAgents() + } + return nil +} + // ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args). // If matched, it reads the skill content and returns the resolved prompt. Otherwise returns "". // diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index cac21541f..c6d3a190f 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -855,6 +855,18 @@ func (r *LocalRuntime) CurrentAgentSkillsToolset() *skills.ToolSet { return nil } +// BackgroundAgents returns a read-only snapshot of the background agent tasks +// spawned via the agent toolset, used by the TUI's live background-agent +// surface. It is exposed only on the local runtime (which runs background +// agents in-process); the App reaches it through an optional interface so +// remote runtimes report none. +func (r *LocalRuntime) BackgroundAgents() []agenttool.TaskInfo { + if r.bgAgents == nil { + return nil + } + return r.bgAgents.Snapshot() +} + // ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content. func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) { currentAgent := r.CurrentAgent() diff --git a/pkg/tools/builtin/agent/agent.go b/pkg/tools/builtin/agent/agent.go index 109be74b9..cee55a284 100644 --- a/pkg/tools/builtin/agent/agent.go +++ b/pkg/tools/builtin/agent/agent.go @@ -89,17 +89,27 @@ const ( taskFailed ) +// Status string values surfaced via TaskInfo.Status (returned by +// taskStatus.String). Exported so callers such as the TUI can match on a task's +// status without depending on the unexported taskStatus enum. +const ( + StatusRunning = "running" + StatusCompleted = "completed" + StatusStopped = "stopped" + StatusFailed = "failed" +) + // String returns a human-readable name for the status. func (s taskStatus) String() string { switch s { case taskRunning: - return "running" + return StatusRunning case taskCompleted: - return "completed" + return StatusCompleted case taskStopped: - return "stopped" + return StatusStopped case taskFailed: - return "failed" + return StatusFailed default: return "unknown" } @@ -225,6 +235,38 @@ func NewHandler(runner Runner) *Handler { } } +// TaskInfo is a lock-safe, read-only view of a background agent task. It exposes +// only fields fixed when the task is created plus the atomically-loaded status, +// so it can be read while the task runs without exposing the internal *task +// (whose result and output fields mutate concurrently). +type TaskInfo struct { + ID string + Agent string + Task string + Status string + StartedAt time.Time +} + +// Snapshot returns a read-only view of every tracked background agent task, +// running and finished. It reads only fields fixed at task creation plus the +// atomic status, so it is safe to call concurrently with up to +// maxConcurrentTasks running task goroutines and never exposes the internal +// *task. This is a pure read model: it does not alter task state or lifecycle. +func (h *Handler) Snapshot() []TaskInfo { + var out []TaskInfo + h.tasks.Range(func(id string, t *task) bool { + out = append(out, TaskInfo{ + ID: id, + Agent: t.agentName, + Task: t.taskDesc, + Status: t.loadStatus().String(), + StartedAt: t.startTime, + }) + return true + }) + return out +} + func newTaskID() string { return "agent_task_" + uuid.New().String() } diff --git a/pkg/tools/builtin/agent/agent_test.go b/pkg/tools/builtin/agent/agent_test.go index 2b64d319d..331f0e32f 100644 --- a/pkg/tools/builtin/agent/agent_test.go +++ b/pkg/tools/builtin/agent/agent_test.go @@ -110,6 +110,62 @@ func TestStatusToString(t *testing.T) { } } +// --- Snapshot --- + +func TestSnapshot_StatusPerTask(t *testing.T) { + cases := []struct { + name string + status taskStatus + want string + }{ + {"running", taskRunning, StatusRunning}, + {"completed", taskCompleted, StatusCompleted}, + {"stopped", taskStopped, StatusStopped}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newTestHandler() + start := time.Now() + tk := insertTask(h, "t1", "researcher", tc.status) + tk.startTime = start + + got := h.Snapshot() + require.Len(t, got, 1) + assert.Equal(t, TaskInfo{ + ID: "t1", + Agent: "researcher", + Task: "test task", + Status: tc.want, + StartedAt: start, + }, got[0]) + }) + } +} + +func TestSnapshot_AllTasks(t *testing.T) { + h := newTestHandler() + insertTask(h, "t1", "researcher", taskRunning) + insertTask(h, "t2", "writer", taskCompleted) + insertTask(h, "t3", "editor", taskStopped) + + got := h.Snapshot() + require.Len(t, got, 3, "Snapshot must return one entry per task, finished tasks included") + + statuses := make(map[string]string, len(got)) + for _, ti := range got { + statuses[ti.ID] = ti.Status + } + assert.Equal(t, map[string]string{ + "t1": StatusRunning, + "t2": StatusCompleted, + "t3": StatusStopped, + }, statuses) +} + +func TestSnapshot_Empty(t *testing.T) { + assert.Empty(t, newTestHandler().Snapshot()) +} + // --- runningTaskCount / totalTaskCount --- func TestTaskCounts(t *testing.T) { @@ -569,6 +625,14 @@ func TestHandler_ConcurrentAccess(t *testing.T) { }) } + // Snapshot reads immutable task fields plus the atomic status concurrently + // with the HandleStop CAS writes below; -race must stay clean. + for range 5 { + wg.Go(func() { + _ = h.Snapshot() + }) + } + for i := range 5 { wg.Add(1) go func(tc tools.ToolCall) { From f70ebff8f5220c7515ce7172aa70236e9ada5e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Mon, 15 Jun 2026 08:12:53 +0200 Subject: [PATCH 2/2] feat(tui): live "Background agents (N)" sidebar panel Concurrent run_background_agent tasks were invisible in the TUI: their events are dropped by runCollecting and the goroutines outlive the per-turn event sink, so there is no live stream to forward. Poll the runtime snapshot instead and surface the fleet: - The chat page starts a 1s tea.Tick when a stream begins and keeps it running while a stream works or any background task is still running (tasks can outlive the turn), feeding sidebar.SetBackgroundAgents. The loop self-stops when idle, so ordinary single-agent turns pay nothing. - The sidebar renders a "Background agents (N)" panel: one row per running task with a colored activity dot, the sub-agent's name in its accent color, and the elapsed run time. It keeps only running tasks (sorted by start time) and hides when empty. - The Phase 2 delegation breadcrumb gains the deferred "+N background" count, fitted so the chain elides before the count is clipped. - Export toolcommon.FormatDuration to reuse the shared elapsed-time formatter for the per-row run time. Refs #3103 --- docs/tools/background-agents/index.md | 4 + .../sidebar/background_agents_test.go | 138 ++++++++++++++++++ pkg/tui/components/sidebar/sidebar.go | 97 ++++++++++-- pkg/tui/components/toolcommon/common.go | 8 +- pkg/tui/components/toolcommon/common_test.go | 4 +- pkg/tui/page/chat/background_agents.go | 61 ++++++++ pkg/tui/page/chat/background_agents_test.go | 76 ++++++++++ pkg/tui/page/chat/chat.go | 8 + pkg/tui/page/chat/runtime_events.go | 5 +- 9 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 pkg/tui/components/sidebar/background_agents_test.go create mode 100644 pkg/tui/page/chat/background_agents.go create mode 100644 pkg/tui/page/chat/background_agents_test.go diff --git a/docs/tools/background-agents/index.md b/docs/tools/background-agents/index.md index e4a1b5b86..82277829d 100644 --- a/docs/tools/background-agents/index.md +++ b/docs/tools/background-agents/index.md @@ -39,6 +39,10 @@ The background agents tool lets an orchestrator dispatch work to sub-agents conc `list_background_agents` takes no parameters. +## Live status in the TUI + +While background tasks are running, the TUI sidebar shows a live **Background agents (N)** panel — one row per running task with a colored activity dot, the sub-agent's name in its accent color, and the elapsed run time. The panel appears only while at least one task is running and clears automatically as tasks finish, so an idle session shows nothing. The same live count is surfaced as a muted `+N background` suffix on the delegation breadcrumb when the orchestrator is simultaneously delegating with `transfer_task`. + ## Configuration ```yaml diff --git a/pkg/tui/components/sidebar/background_agents_test.go b/pkg/tui/components/sidebar/background_agents_test.go new file mode 100644 index 000000000..0277d0263 --- /dev/null +++ b/pkg/tui/components/sidebar/background_agents_test.go @@ -0,0 +1,138 @@ +package sidebar + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/service" +) + +func runningTask(id, agent string, startedAt time.Time) agenttool.TaskInfo { + return agenttool.TaskInfo{ + ID: id, + Agent: agent, + Task: agent + " task", + Status: agenttool.StatusRunning, + StartedAt: startedAt, + } +} + +func TestBackgroundAgentsSection_HiddenWhenEmpty(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + assert.Empty(t, m.backgroundAgentsSection(40)) +} + +func TestBackgroundAgentsSection_RendersRunningRoster(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + now := time.Now() + m.SetBackgroundAgents([]agenttool.TaskInfo{ + runningTask("t1", "researcher", now.Add(-90*time.Second)), + runningTask("t2", "writer", now.Add(-5*time.Second)), + }) + + out := ansi.Strip(m.backgroundAgentsSection(40)) + + assert.Contains(t, out, "Background agents (2)") + assert.Contains(t, out, "●") + assert.Contains(t, out, "researcher") + assert.Contains(t, out, "writer") + // Elapsed run time is shown per row (reusing the shared duration formatter). + assert.Contains(t, out, "1m30s") +} + +// TestSetBackgroundAgents_FiltersToRunningAndSorts verifies the read-model seam: +// finished tasks linger in the runtime snapshot but are dropped from the panel, +// and the surviving running tasks are ordered by start time so rows stay put +// across polls. +func TestSetBackgroundAgents_FiltersToRunningAndSorts(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + now := time.Now() + m.SetBackgroundAgents([]agenttool.TaskInfo{ + {ID: "done", Agent: "old", Status: agenttool.StatusCompleted, StartedAt: now.Add(-time.Hour)}, + runningTask("late", "writer", now.Add(-5*time.Second)), + runningTask("early", "researcher", now.Add(-90*time.Second)), + {ID: "stopped", Agent: "cancelled", Status: agenttool.StatusStopped, StartedAt: now}, + }) + + require.Len(t, m.backgroundAgents, 2) + assert.Equal(t, "researcher", m.backgroundAgents[0].Agent) + assert.Equal(t, "writer", m.backgroundAgents[1].Agent) +} + +// TestSetBackgroundAgents_DrainsToEmpty verifies the panel hides again once the +// snapshot no longer contains running tasks. +func TestSetBackgroundAgents_DrainsToEmpty(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + require.Len(t, m.backgroundAgents, 1) + + m.SetBackgroundAgents([]agenttool.TaskInfo{ + {ID: "t1", Agent: "researcher", Status: agenttool.StatusCompleted, StartedAt: time.Now()}, + }) + assert.Empty(t, m.backgroundAgents) + assert.Empty(t, m.backgroundAgentsSection(40)) +} + +func TestBackgroundAgentsSection_InRenderSections(t *testing.T) { + t.Parallel() + + m := New(&service.SessionState{}).(*model) + m.SetSize(40, 100) + + without := strings.Join(m.renderSections(35), "\n") + assert.NotContains(t, without, "Background agents") + + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + with := ansi.Strip(strings.Join(m.renderSections(35), "\n")) + assert.Contains(t, with, "Background agents (1)") + assert.Contains(t, with, "researcher") +} + +// TestDelegationBreadcrumb_AppendsBackgroundCount covers wiring the live +// background-agent count into the Phase 2 delegation breadcrumb. +func TestDelegationBreadcrumb_AppendsBackgroundCount(t *testing.T) { + t.Parallel() + + t.Run("appends +N background while delegating", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root", "librarian"} + m.SetBackgroundAgents([]agenttool.TaskInfo{ + runningTask("t1", "researcher", time.Now()), + runningTask("t2", "writer", time.Now()), + }) + + out := ansi.Strip(m.delegationBreadcrumb(80)) + assert.Contains(t, out, "root ⏵ librarian") + assert.Contains(t, out, "+2 background") + }) + + t.Run("no addendum without background agents", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root", "librarian"} + assert.NotContains(t, ansi.Strip(m.delegationBreadcrumb(80)), "background") + }) + + t.Run("no breadcrumb at depth <= 1 even with background agents", func(t *testing.T) { + t.Parallel() + m := New(&service.SessionState{}).(*model) + m.agentChain = []string{"root"} + m.SetBackgroundAgents([]agenttool.TaskInfo{runningTask("t1", "researcher", time.Now())}) + assert.Empty(t, m.delegationBreadcrumb(80)) + }) +} diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index c281df11d..54c65b509 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tools" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" "github.com/docker/docker-agent/pkg/tui/components/scrollbar" "github.com/docker/docker-agent/pkg/tui/components/scrollview" "github.com/docker/docker-agent/pkg/tui/components/spinner" @@ -54,6 +55,9 @@ type Model interface { SetAgentSwitching(switching bool) SetToolsetInfo(availableTools int, loading bool) SetSkillsInfo(availableSkills int) + // SetBackgroundAgents updates the live background-agent roster shown in the + // "Background agents (N)" section and the delegation breadcrumb's "+N" count. + SetBackgroundAgents(tasks []agenttool.TaskInfo) SetSessionStarred(starred bool) SetQueuedMessages(messages ...string) GetSize() (width, height int) @@ -141,14 +145,15 @@ type model struct { rootSessionID string // Main (top-level) session, shown when no stream is active scrollview *scrollview.Model workingDirectory string - gitBranchName string // current git branch, empty if not in a repo - queuedMessages []string // Truncated preview of queued messages - streamCancelled bool // true after ESC cancel until next StreamStartedEvent - collapsed bool // true when sidebar is collapsed - titleRegenerating bool // true when title is being regenerated by AI - titleGenerated bool // true once a title has been generated or set (hides pencil until then) - preferredWidth int // user's preferred width (persisted across collapse/expand) - editingTitle bool // true when inline title editing is active + gitBranchName string // current git branch, empty if not in a repo + queuedMessages []string // Truncated preview of queued messages + backgroundAgents []agenttool.TaskInfo // Running background-agent tasks (sorted by start time) + streamCancelled bool // true after ESC cancel until next StreamStartedEvent + collapsed bool // true when sidebar is collapsed + titleRegenerating bool // true when title is being regenerated by AI + titleGenerated bool // true once a title has been generated or set (hides pencil until then) + preferredWidth int // user's preferred width (persisted across collapse/expand) + editingTitle bool // true when inline title editing is active titleInput textinput.Model lastTitleClickTime time.Time // for double-click detection on title @@ -320,6 +325,32 @@ func (m *model) SetSkillsInfo(availableSkills int) { m.invalidateCache() } +// SetBackgroundAgents updates the live background-agent roster from a runtime +// snapshot. Only running tasks are kept (finished tasks linger in the snapshot) +// so the section drains as work completes, and they are sorted by start time so +// rows stay put across polls. It no-ops while the running set is and stays empty +// to avoid needless cache invalidation during ordinary turns. +func (m *model) SetBackgroundAgents(tasks []agenttool.TaskInfo) { + var running []agenttool.TaskInfo + for _, t := range tasks { + if t.Status == agenttool.StatusRunning { + running = append(running, t) + } + } + slices.SortFunc(running, func(a, b agenttool.TaskInfo) int { + if c := a.StartedAt.Compare(b.StartedAt); c != 0 { + return c + } + return strings.Compare(a.ID, b.ID) + }) + + if len(running) == 0 && len(m.backgroundAgents) == 0 { + return + } + m.backgroundAgents = running + m.invalidateCache() +} + // SetSessionStarred sets the starred status of the current session func (m *model) SetSessionStarred(starred bool) { m.sessionStarred = starred @@ -1020,6 +1051,7 @@ func (m *model) renderSections(contentWidth int) []string { m.buildAgentClickZones(agentSectionStart, lines) appendSection(m.toolsetInfo(contentWidth)) + appendSection(m.backgroundAgentsSection(contentWidth)) m.todoComp.SetSize(contentWidth) appendSection(strings.TrimSuffix(m.todoComp.Render(), "\n")) @@ -1277,14 +1309,25 @@ func (m *model) agentInfo(contentWidth int) string { // returns "" unless the chain is deeper than the root (len > 1). When the full // chain would exceed contentWidth the middle is elided as "root ⏵ … ⏵ leaf". // -// TODO(#3103, Appendix C.2): append a muted "+N background" count here once a -// background-task snapshot is available. +// While background agents run concurrently, a muted "+N background" addendum is +// appended to hint at the off-chain work the Background agents panel lists in +// full. The chain is fitted against the width left after the addendum so the +// count is never clipped. func (m *model) delegationBreadcrumb(contentWidth int) string { chain := m.agentChain if len(chain) <= 1 { return "" } + suffix := "" + if n := len(m.backgroundAgents); n > 0 { + suffix = styles.MutedStyle.Render(fmt.Sprintf(" +%d background", n)) + } + avail := contentWidth + if avail > 0 { + avail -= ansi.StringWidth(suffix) + } + sep := styles.MutedStyle.Render(" ⏵ ") colored := func(name string) string { return styles.AgentAccentStyleFor(name).Render(name) } @@ -1295,16 +1338,16 @@ func (m *model) delegationBreadcrumb(contentWidth int) string { b.WriteString(colored(name)) } full := b.String() - if contentWidth <= 0 || ansi.StringWidth(full) <= contentWidth { - return full + if avail <= 0 || ansi.StringWidth(full) <= avail { + return full + suffix } // Too wide: keep the root and the deepest agent, elide the middle. elided := colored(chain[0]) + sep + styles.MutedStyle.Render("…") + sep + colored(chain[len(chain)-1]) - if ansi.StringWidth(elided) <= contentWidth { - return elided + if ansi.StringWidth(elided) <= avail { + return elided + suffix } - return ansi.Truncate(full, contentWidth, "…") + return ansi.Truncate(full, avail, "…") + suffix } // hasDelegationBreadcrumb reports whether agentInfo prepends a delegation @@ -1486,6 +1529,30 @@ func (m *model) renderToggleIndicator(label, shortcut string, contentWidth int) return indicator + shortcutStyled } +// backgroundAgentsSection renders the live "Background agents (N)" panel: one +// row per running background-agent task (run_background_agent), each with a +// colored activity dot and the agent name in its accent color, plus the elapsed +// run time right-aligned and muted. It returns "" when none are running so the +// panel surfaces only while concurrent background work is in flight and drains +// as tasks finish. +func (m *model) backgroundAgentsSection(contentWidth int) string { + if len(m.backgroundAgents) == 0 { + return "" + } + + lines := make([]string, len(m.backgroundAgents)) + for i, t := range m.backgroundAgents { + accent := styles.AgentAccentStyleFor(t.Agent) + label := accent.Render("●") + " " + accent.Render(t.Agent) + elapsed := styles.MutedStyle.Render(toolcommon.FormatDuration(time.Since(t.StartedAt))) + gap := max(contentWidth-lipgloss.Width(label)-lipgloss.Width(elapsed), 1) + lines[i] = label + strings.Repeat(" ", gap) + elapsed + } + + title := fmt.Sprintf("Background agents (%d)", len(m.backgroundAgents)) + return m.renderTab(title, strings.Join(lines, "\n"), contentWidth) +} + // SetSize sets the dimensions of the component func (m *model) SetSize(width, height int) tea.Cmd { if m.width == width && m.height == height { diff --git a/pkg/tui/components/toolcommon/common.go b/pkg/tui/components/toolcommon/common.go index 94dc86c88..c6a00e41f 100644 --- a/pkg/tui/components/toolcommon/common.go +++ b/pkg/tui/components/toolcommon/common.go @@ -116,7 +116,7 @@ func Icon(msg *types.Message, inProgress spinner.Spinner) string { if msg.StartedAt != nil { elapsed := time.Since(*msg.StartedAt) if elapsed >= time.Second { - icon += " " + styles.ToolMessageStyle.Render(formatDuration(elapsed)) + icon += " " + styles.ToolMessageStyle.Render(FormatDuration(elapsed)) } } return icon @@ -146,8 +146,10 @@ func LongRunningWarning(msg *types.Message) string { return "⚠ Tool call running for over 60s. The tool may be waiting for external input. Press Esc to cancel." } -// formatDuration formats a duration as a human-readable string like "5s", "1m30s", "2m15s". -func formatDuration(d time.Duration) string { +// FormatDuration formats a duration as a compact human-readable string like +// "5s", "1m30s", "2m". Exported so other components (e.g. the sidebar's +// background-agents panel) can render elapsed times consistently. +func FormatDuration(d time.Duration) string { d = d.Truncate(time.Second) if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) diff --git a/pkg/tui/components/toolcommon/common_test.go b/pkg/tui/components/toolcommon/common_test.go index d27cc2af0..290f7538f 100644 --- a/pkg/tui/components/toolcommon/common_test.go +++ b/pkg/tui/components/toolcommon/common_test.go @@ -733,9 +733,9 @@ func TestFormatDuration(t *testing.T) { } for _, tt := range tests { t.Run(tt.want, func(t *testing.T) { - got := formatDuration(tt.d) + got := FormatDuration(tt.d) if got != tt.want { - t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + t.Errorf("FormatDuration(%v) = %q, want %q", tt.d, got, tt.want) } }) } diff --git a/pkg/tui/page/chat/background_agents.go b/pkg/tui/page/chat/background_agents.go new file mode 100644 index 000000000..32dbe025f --- /dev/null +++ b/pkg/tui/page/chat/background_agents.go @@ -0,0 +1,61 @@ +package chat + +import ( + "time" + + tea "charm.land/bubbletea/v2" + + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" +) + +// backgroundAgentPollInterval is the cadence at which the chat page re-reads the +// runtime's background-agent snapshot to refresh the sidebar's live status +// panel. One second keeps the elapsed times current without measurable cost. +const backgroundAgentPollInterval = time.Second + +// backgroundAgentPollMsg ticks the background-agent poll loop. +type backgroundAgentPollMsg struct{} + +// startBackgroundAgentPoll starts the background-agent poll loop unless it is +// already running. The loop self-reschedules from handleBackgroundAgentPoll and +// stops once no work remains, so it adds nothing to ordinary single-agent turns. +func (p *chatPage) startBackgroundAgentPoll() tea.Cmd { + if p.backgroundPollActive { + return nil + } + p.backgroundPollActive = true + return scheduleBackgroundAgentPoll() +} + +func scheduleBackgroundAgentPoll() tea.Cmd { + return tea.Tick(backgroundAgentPollInterval, func(time.Time) tea.Msg { + return backgroundAgentPollMsg{} + }) +} + +// handleBackgroundAgentPoll pushes the latest runtime snapshot to the sidebar +// and keeps the loop alive while a stream is working or background agents are +// still running. When both are idle it pushes the final (empty) snapshot so the +// panel clears, then stops polling until the next stream starts. +func (p *chatPage) handleBackgroundAgentPoll() tea.Cmd { + tasks := p.app.BackgroundAgents() + p.sidebar.SetBackgroundAgents(tasks) + + if p.working || hasRunningBackgroundAgent(tasks) { + return scheduleBackgroundAgentPoll() + } + p.backgroundPollActive = false + return nil +} + +// hasRunningBackgroundAgent reports whether any task in the snapshot is still +// running, gating whether the poll loop continues after the active stream ends +// (background agents can outlive the turn that spawned them). +func hasRunningBackgroundAgent(tasks []agenttool.TaskInfo) bool { + for _, t := range tasks { + if t.Status == agenttool.StatusRunning { + return true + } + } + return false +} diff --git a/pkg/tui/page/chat/background_agents_test.go b/pkg/tui/page/chat/background_agents_test.go new file mode 100644 index 000000000..53715461f --- /dev/null +++ b/pkg/tui/page/chat/background_agents_test.go @@ -0,0 +1,76 @@ +package chat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/session" + agenttool "github.com/docker/docker-agent/pkg/tools/builtin/agent" + "github.com/docker/docker-agent/pkg/tui/service" +) + +func TestHasRunningBackgroundAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tasks []agenttool.TaskInfo + want bool + }{ + {"nil", nil, false}, + {"only finished", []agenttool.TaskInfo{ + {Status: agenttool.StatusCompleted}, + {Status: agenttool.StatusStopped}, + }, false}, + {"one running", []agenttool.TaskInfo{ + {Status: agenttool.StatusCompleted}, + {Status: agenttool.StatusRunning}, + }, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, hasRunningBackgroundAgent(tc.tasks)) + }) + } +} + +// TestStartBackgroundAgentPoll_RunsSingleLoop guards the idempotency of the poll +// starter: nested streams each call it, but only one tea.Tick loop must run. +func TestStartBackgroundAgentPoll_RunsSingleLoop(t *testing.T) { + t.Parallel() + + p := &chatPage{} + + require.NotNil(t, p.startBackgroundAgentPoll(), "first start schedules a tick") + assert.True(t, p.backgroundPollActive) + assert.Nil(t, p.startBackgroundAgentPoll(), "second start is a no-op while the loop runs") +} + +func TestHandleBackgroundAgentPoll_StopsWhenIdle(t *testing.T) { + t.Parallel() + + sess := session.New() + p := New(app.New(t.Context(), queueTestRuntime{}, sess), service.NewSessionState(sess)).(*chatPage) + p.backgroundPollActive = true + p.working = false + + assert.Nil(t, p.handleBackgroundAgentPoll(), "no stream and no background tasks stops the loop") + assert.False(t, p.backgroundPollActive) +} + +func TestHandleBackgroundAgentPoll_ContinuesWhileWorking(t *testing.T) { + t.Parallel() + + sess := session.New() + p := New(app.New(t.Context(), queueTestRuntime{}, sess), service.NewSessionState(sess)).(*chatPage) + p.backgroundPollActive = true + p.working = true + + assert.NotNil(t, p.handleBackgroundAgentPoll(), "an active stream keeps the poll loop alive") + assert.True(t, p.backgroundPollActive) +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index e5df89dfb..0fc78c84b 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -146,6 +146,10 @@ type chatPage struct { agentStack []string // agent per active stream level; len(agentStack)==streamDepth streamStartTime time.Time + // backgroundPollActive guards the background-agent snapshot poll so only one + // tea.Tick loop runs at a time (see background_agents.go). + backgroundPollActive bool + // 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 @@ -394,6 +398,10 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case msgtypes.ClearQueueMsg: return p.handleClearQueue() + case backgroundAgentPollMsg: + cmd := p.handleBackgroundAgentPoll() + return p, cmd + case msgtypes.ThemeChangedMsg: // Theme changed - forward to all child components to invalidate caches var cmds []tea.Cmd diff --git a/pkg/tui/page/chat/runtime_events.go b/pkg/tui/page/chat/runtime_events.go index 1e389a2c5..e7712a214 100644 --- a/pkg/tui/page/chat/runtime_events.go +++ b/pkg/tui/page/chat/runtime_events.go @@ -212,7 +212,10 @@ func (p *chatPage) handleStreamStarted(msg *runtime.StreamStartedEvent) tea.Cmd spinnerCmd := p.setWorking(true) pendingCmd := p.setPendingResponse(true) sidebarCmd := p.forwardToSidebar(msg) - return tea.Batch(pendingCmd, spinnerCmd, sidebarCmd) + // Begin polling the runtime's background-agent snapshot while work is in + // flight; the loop self-stops once no stream and no background task remain. + pollCmd := p.startBackgroundAgentPoll() + return tea.Batch(pendingCmd, spinnerCmd, sidebarCmd, pollCmd) } func (p *chatPage) handleAgentChoice(msg *runtime.AgentChoiceEvent) tea.Cmd {