diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
index 9132782fd..b34d81fcf 100644
--- a/docs/features/tui/index.md
+++ b/docs/features/tui/index.md
@@ -79,9 +79,52 @@ Type `/` during a session to see available commands, or press Ctrl+Ctrl+left-click any agent** does the same — a fallback for terminals that don't forward right-clicks.
+- **Left-click** always switches to the agent.
+
+The title is rendered in the agent's accent color. Sections appear in this order, and any empty section is omitted:
+
+- **Description** — the agent's wrapped description.
+- **Live state** — a `● current agent` line when the inspected agent is the one currently running.
+- **Model / Fallback / Thinking** — the `provider/model`, any fallback models, and the gauge + value thinking line (omitted for models with no selectable thinking, e.g. harness-backed agents).
+- **Sub-agents (N) / Handoffs (N) / Skills (N)** — compact, inline, comma-separated lists wrapped to the dialog width.
+- **Limits** — the configured per-agent limits that are set, e.g. `Limits: max-iter 50 · history 40 · max-tool-calls 5`.
+- **Options** — the enabled option flags, e.g. `Options: add-date · add-environment-info · redact-secrets`.
+- **Toolsets (N)** — one line per toolset with a status marker, its name, kind, and tool count, followed by the indented tool names.
+- **Commands (N)** — the slash commands the agent defines, each with its description.
+
+Each toolset carries a single-width status marker reflecting its **live** lifecycle: `●` started (serving), `○` stopped (not yet started), or `⚠` error. The tools listed under a toolset are the **live** tool names when it has started; for a toolset that has not started, the inspector instead shows its declared `tools:` allow-list prefixed with `declared:` (and shows nothing when the toolset declares no allow-list and therefore serves every tool). This lets you see both what an agent is configured with and what is actually running, even before the agent has been used.
+
+The dialog scrolls when the content is long; press Esc to close it. Remote runtimes (which hold no local team config) degrade gracefully — the config-derived sections are simply omitted.
+
+Model identifiers on line 2 are truncated **from the left** (e.g. `…claude-sonnet-4-6`) only when they overflow, so the informative tail (variant/version) is preserved. As the sidebar narrows the model keeps its own line, and near the minimum width line 1's gauge collapses to a single cell to keep the name readable.
+
+The thinking state of each model is shown with a gauge + value on the card and a gauge or badge on the row (no `✻` glyph):
+
+| Model state | Card line | Row badge |
+| ---------------------- | ------------------------------ | ---------------------- |
+| Effort level | `thinking ▰▰▰▰▱▱ high` | `▰▰▰▰▱▱` (effort gauge) |
+| Adaptive budget | `thinking auto adaptive` | `auto` |
+| Token budget | `thinking ◉ 8.2K tokens` | `◉ 8.2K` |
+| Disabled (capable) | `thinking ▱▱▱▱▱▱ off` (dimmed) | `▱▱▱▱▱▱` (empty gauge) |
+| Not reasoning-capable | _(omitted)_ | _(omitted)_ |
+
+The **effort gauge** is a fixed-width six-cell indicator (`▰` filled, `▱` empty) so the badge column stays aligned. It maps the six selectable levels one-to-one onto filled-cell counts — `minimal` → `▰▱▱▱▱▱`, `low` → `▰▰▱▱▱▱`, `medium` → `▰▰▰▱▱▱`, `high` → `▰▰▰▰▱▱`, `xhigh` → `▰▰▰▰▰▱`, `max` → `▰▰▰▰▰▰` — so the cell count alone is lossless, with a low→high color ramp as a secondary cue. A capable-but-disabled model shows a dim empty gauge (`▱▱▱▱▱▱` `off`), adaptive budgets show `auto`, and token budgets keep `◉ `. The same gauge + value renders on the focus card, the Agent Inspector, and the row.
+
+Harness-backed agents (e.g. `claude-code`) show the harness type as their model and no thinking gauge. Press **Shift+Tab** to cycle the current model's thinking-effort level; a `✻ Thinking: ` toast confirms the change (useful when the sidebar is hidden).
+
### Thinking and Tool Details
-Reasoning/thinking blocks are collapsed by default. When collapsed, the TUI shows a short preview and compact tool summaries. Expand a block to see the full thinking content and the real tool renderers, including detailed tool output such as file edit diffs.
+Reasoning/thinking blocks are collapsed by default and carry a `Thinking` header badge. When collapsed, the TUI shows a short preview and compact tool summaries. Expand a block to see the full thinking content and the real tool renderers, including detailed tool output such as file edit diffs.
To start new sessions with thinking/tool blocks expanded by default, set `expand_thinking` in your user config:
@@ -188,7 +231,7 @@ Customize session titles to make them more meaningful and easier to find. By def
| Ctrl+R | Reverse history search (search previous inputs) |
| Ctrl+G | Cancel reverse history search |
| Ctrl+S | Cycle to next agent in the team |
-| Shift+Tab | Cycle the current model's thinking-effort level |
+| Shift+Tab | Cycle the current model's thinking-effort level (shows a `✻ Thinking: ` toast) |
| Ctrl+1 – 9 | Switch directly to agent _N_ in the team list |
| Ctrl+T | Open a new tab (additional agent session) |
| Ctrl+W | Close the current tab |
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
index 802024355..bfe9c9c04 100644
--- a/pkg/agent/agent.go
+++ b/pkg/agent/agent.go
@@ -324,6 +324,16 @@ func (a *Agent) HasHarness() bool {
return a.harness != nil
}
+// HarnessType returns the external harness provider type (e.g. "claude-code"),
+// or an empty string when the agent is not harness-backed. It exposes the
+// harness type without leaking the config struct to callers.
+func (a *Agent) HarnessType() string {
+ if a.harness == nil {
+ return ""
+ }
+ return a.harness.Type
+}
+
// Hooks returns the hooks configuration for this agent.
func (a *Agent) Hooks() *latest.HooksConfig {
return a.hooks
diff --git a/pkg/app/app.go b/pkg/app/app.go
index 3269ae0cc..d2a5a3dad 100644
--- a/pkg/app/app.go
+++ b/pkg/app/app.go
@@ -211,6 +211,26 @@ func (a *App) CurrentAgentTools(ctx context.Context) ([]tools.Tool, error) {
return a.runtime.CurrentAgentTools(ctx)
}
+// agentConfigProvider is an optional runtime capability: exposing an agent's
+// static configuration (toolsets, sub-agents, handoffs, fallbacks) by name.
+// Only the local runtime (which holds the team) implements it; remote runtimes
+// don't, so the agent-details config sections are simply omitted for them.
+type agentConfigProvider interface {
+ AgentConfigInfo(agentName string) runtime.AgentConfigInfo
+}
+
+// AgentConfigInfo returns the named agent's static configuration for the
+// read-only agent-details dialog, or the zero value when it can't be resolved
+// (remote runtime or unknown agent). It reads resolved config only and starts
+// no toolsets.
+func (a *App) AgentConfigInfo(agentName string) runtime.AgentConfigInfo {
+ cp, ok := a.runtime.(agentConfigProvider)
+ if !ok {
+ return runtime.AgentConfigInfo{}
+ }
+ return cp.AgentConfigInfo(agentName)
+}
+
// CurrentAgentToolsetStatuses returns lifecycle status for each toolset of
// the active agent.
func (a *App) CurrentAgentToolsetStatuses() []tools.ToolsetStatus {
diff --git a/pkg/runtime/agent_inspector_test.go b/pkg/runtime/agent_inspector_test.go
new file mode 100644
index 000000000..928c26d99
--- /dev/null
+++ b/pkg/runtime/agent_inspector_test.go
@@ -0,0 +1,164 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/agent"
+ "github.com/docker/docker-agent/pkg/config/latest"
+ "github.com/docker/docker-agent/pkg/team"
+ "github.com/docker/docker-agent/pkg/tools"
+ "github.com/docker/docker-agent/pkg/tools/lifecycle"
+)
+
+// toolListToolset is a minimal ToolSet that reports a fixed list of tools and a
+// description. It implements neither Statable nor Startable, so its lifecycle
+// state is driven purely by the StartableToolSet wrapper (started vs not) — the
+// same path the built-in filesystem/shell toolsets take.
+type toolListToolset struct {
+ desc string
+ names []string
+}
+
+func (s *toolListToolset) Tools(context.Context) ([]tools.Tool, error) {
+ out := make([]tools.Tool, 0, len(s.names))
+ for _, n := range s.names {
+ out = append(out, tools.Tool{Name: n})
+ }
+ return out, nil
+}
+
+func (s *toolListToolset) Describe() string { return s.desc }
+
+// TestAgentToolsetStatuses verifies the named-agent lifecycle accessor mirrors
+// CurrentAgentToolsetStatuses: it maps each toolset's live state (ready,
+// stopped, failed + error/restart count) in declaration order, and yields nil
+// for an unknown agent.
+func TestAgentToolsetStatuses(t *testing.T) {
+ t.Parallel()
+
+ boom := errors.New("kaboom")
+ ready := &statefulToolset{desc: "ready-ts", info: lifecycle.StateInfo{State: lifecycle.StateReady}}
+ stopped := &statefulToolset{desc: "stopped-ts", info: lifecycle.StateInfo{State: lifecycle.StateStopped}}
+ failed := &statefulToolset{desc: "failed-ts", info: lifecycle.StateInfo{State: lifecycle.StateFailed, LastError: boom, RestartCount: 2}}
+
+ root := agent.New("root", "", agent.WithToolSets(ready, stopped, failed))
+ tm := team.New(team.WithAgents(root))
+ r := &LocalRuntime{team: tm, agents: newAgentRouter(tm, "root")}
+
+ statuses := r.AgentToolsetStatuses("root")
+ require.Len(t, statuses, 3)
+ assert.Equal(t, lifecycle.StateReady, statuses[0].State)
+ assert.Equal(t, lifecycle.StateStopped, statuses[1].State)
+ assert.Equal(t, lifecycle.StateFailed, statuses[2].State)
+ require.ErrorIs(t, statuses[2].LastError, boom)
+ assert.Equal(t, 2, statuses[2].RestartCount)
+
+ assert.Nil(t, r.AgentToolsetStatuses("missing"), "unknown agent yields nil")
+}
+
+// TestAgentConfigInfo_Inspector exercises the full inspector dataset: the
+// static config (sub-agents, handoffs, fallbacks, skills, limits, option flags)
+// combined with live toolset state. The filesystem toolset is started so it
+// reports live tool names; git stays stopped and must fall back to its declared
+// allow-list. The instruction is never surfaced.
+func TestAgentConfigInfo_Inspector(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ prov := &mockProvider{id: "anthropic/claude-opus-4-8"}
+
+ sub := agent.New("coder", "")
+ hand := agent.New("planner", "")
+
+ fsTS := tools.WithName(&toolListToolset{desc: "fs", names: []string{"read_file", "write_file"}}, "filesystem")
+ gitTS := tools.WithName(&toolListToolset{desc: "git"}, "git")
+
+ root := agent.New("root", "secret system instruction",
+ agent.WithModel(prov),
+ agent.WithFallbackModel(prov),
+ agent.WithSubAgents(sub),
+ agent.WithHandoffs(hand),
+ agent.WithToolSets(fsTS, gitTS),
+ agent.WithMaxIterations(50),
+ agent.WithNumHistoryItems(40),
+ agent.WithMaxConsecutiveToolCalls(5),
+ agent.WithAddDate(true),
+ agent.WithRedactSecrets(true),
+ )
+
+ cfg := latest.AgentConfig{
+ Name: "root",
+ CodeModeTools: true,
+ UseSkills: []string{"code-review"},
+ Skills: latest.SkillsConfig{
+ Include: []string{"debugging"},
+ Inline: []latest.InlineSkill{{Name: "refactor"}},
+ },
+ Toolsets: []latest.Toolset{
+ {Type: "filesystem", Tools: []string{"read_file", "write_file", "edit_file"}},
+ {Type: "git", Tools: []string{"status", "commit"}},
+ },
+ }
+
+ tm := team.New(
+ team.WithAgents(root, sub, hand),
+ team.WithAgentConfigs(map[string]latest.AgentConfig{"root": cfg}),
+ )
+ r := &LocalRuntime{team: tm, agents: newAgentRouter(tm, "root")}
+
+ started := root.ToolSets()[0].(*tools.StartableToolSet)
+ require.NoError(t, started.Start(ctx))
+
+ got := r.AgentConfigInfo("root")
+
+ assert.Equal(t, []string{"coder"}, got.SubAgents)
+ assert.Equal(t, []string{"planner"}, got.Handoffs)
+ assert.Equal(t, []string{"anthropic/claude-opus-4-8"}, got.Fallbacks)
+ assert.Equal(t, []string{"code-review", "debugging", "refactor"}, got.Skills)
+
+ assert.Equal(t, 50, got.MaxIterations)
+ assert.Equal(t, 40, got.NumHistoryItems)
+ assert.Equal(t, 5, got.MaxConsecutiveToolCalls)
+
+ assert.Equal(t, []string{"add-date", "redact-secrets", "code-mode-tools"}, got.Options)
+ assert.True(t, got.IsCurrent)
+
+ require.Len(t, got.Toolsets, 2)
+ fs := got.Toolsets[0]
+ assert.Equal(t, "filesystem", fs.Name)
+ assert.Equal(t, ToolsetStarted, fs.State)
+ assert.Equal(t, []string{"read_file", "write_file"}, fs.Tools, "started toolset reports live tool names")
+
+ git := got.Toolsets[1]
+ assert.Equal(t, "git", git.Name)
+ assert.Equal(t, ToolsetStopped, git.State)
+ assert.Equal(t, []string{"status", "commit"}, git.Tools, "stopped toolset reports its declared allow-list")
+}
+
+// TestAgentConfigInfo_Degrades verifies graceful degradation: an unknown agent
+// yields the zero value, and a known agent on a team with no retained configs
+// (e.g. the remote-style path) reports IsCurrent correctly and omits the
+// config-only sections (skills, declared toolsets).
+func TestAgentConfigInfo_Degrades(t *testing.T) {
+ t.Parallel()
+
+ prov := &mockProvider{id: "openai/gpt-5"}
+ root := agent.New("root", "", agent.WithModel(prov))
+ other := agent.New("other", "", agent.WithModel(prov))
+ tm := team.New(team.WithAgents(root, other)) // no retained configs
+
+ r := &LocalRuntime{team: tm, agents: newAgentRouter(tm, "root")}
+
+ assert.Equal(t, AgentConfigInfo{}, r.AgentConfigInfo("missing"), "unknown agent -> zero value")
+
+ got := r.AgentConfigInfo("other")
+ assert.False(t, got.IsCurrent, "non-current agent")
+ assert.Nil(t, got.Skills, "no skills without retained config")
+ assert.Nil(t, got.Options, "no enabled options")
+ assert.Nil(t, got.Toolsets, "agent has no toolsets")
+}
diff --git a/pkg/runtime/agent_tools_test.go b/pkg/runtime/agent_tools_test.go
new file mode 100644
index 000000000..9db3cd2d0
--- /dev/null
+++ b/pkg/runtime/agent_tools_test.go
@@ -0,0 +1,27 @@
+package runtime
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestUniqueNames verifies the helper backing AgentConfigInfo: it drops empty
+// names and de-duplicates, preserving first-seen order by default (so
+// declaration/priority order survives) and sorting case-insensitively when
+// requested, keeping the agent-details config lines stable and readable.
+func TestUniqueNames(t *testing.T) {
+ t.Parallel()
+
+ // Default: first-seen order preserved, empties dropped, duplicates removed.
+ got := uniqueNames([]string{"shell", "", "filesystem", "shell", "git"}, false)
+ assert.Equal(t, []string{"shell", "filesystem", "git"}, got)
+
+ // Sorted: case-insensitive ordering after de-duping. A lowercase name sorts
+ // before an uppercase one alphabetically, unlike a raw byte comparison.
+ sorted := uniqueNames([]string{"Zebra", "apple", "apple"}, true)
+ assert.Equal(t, []string{"apple", "Zebra"}, sorted)
+
+ assert.Empty(t, uniqueNames(nil, false))
+ assert.Empty(t, uniqueNames([]string{"", ""}, true))
+}
diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go
index 4cedc03ef..3147c24c7 100644
--- a/pkg/runtime/event.go
+++ b/pkg/runtime/event.go
@@ -559,8 +559,9 @@ type AgentDetails struct {
Provider string `json:"provider"`
Model string `json:"model"`
// Thinking is a short label describing the model's current thinking-effort
- // level (e.g. "high", "off"). Empty when the model has no selectable
- // thinking configuration.
+ // configuration: an effort level (e.g. "high"), "adaptive", a decimal token
+ // count for token-based budgets, or "off" when disabled. Empty when the
+ // model has no selectable thinking configuration.
Thinking string `json:"thinking,omitempty"`
Commands types.Commands `json:"commands,omitempty"`
}
diff --git a/pkg/runtime/model_switcher_test.go b/pkg/runtime/model_switcher_test.go
index 435ca1f9b..5819b45c2 100644
--- a/pkg/runtime/model_switcher_test.go
+++ b/pkg/runtime/model_switcher_test.go
@@ -155,8 +155,8 @@ func TestAgentThinkingLabel(t *testing.T) {
{"reasoning model, no budget shows off", latest.ModelConfig{Provider: "openai", Model: "gpt-5"}, "off"},
{"reasoning model with effort", latest.ModelConfig{Provider: "openai", Model: "gpt-5", ThinkingBudget: &latest.ThinkingBudget{Effort: "high"}}, "high"},
{"reasoning model disabled shows off", latest.ModelConfig{Provider: "openai", Model: "gpt-5", ThinkingBudget: &latest.ThinkingBudget{Effort: "none"}}, "off"},
- {"adaptive budget shows on", latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-7", ThinkingBudget: &latest.ThinkingBudget{Effort: "adaptive"}}, "on"},
- {"token budget shows on", latest.ModelConfig{Provider: "anthropic", Model: "claude-sonnet-4-5", ThinkingBudget: &latest.ThinkingBudget{Tokens: 4096}}, "on"},
+ {"adaptive budget shows adaptive", latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-7", ThinkingBudget: &latest.ThinkingBudget{Effort: "adaptive"}}, "adaptive"},
+ {"token budget shows token count", latest.ModelConfig{Provider: "anthropic", Model: "claude-sonnet-4-5", ThinkingBudget: &latest.ThinkingBudget{Tokens: 4096}}, "4096"},
{"non-reasoning model hides line", latest.ModelConfig{Provider: "openai", Model: "gpt-4o"}, ""},
}
diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go
index 586610575..20f687d35 100644
--- a/pkg/runtime/runtime.go
+++ b/pkg/runtime/runtime.go
@@ -1,6 +1,7 @@
package runtime
import (
+ "cmp"
"context"
"errors"
"fmt"
@@ -8,6 +9,7 @@ import (
"maps"
"os"
"slices"
+ "strconv"
"strings"
"sync"
"time"
@@ -17,6 +19,7 @@ import (
"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
+ "github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/hooks"
@@ -652,6 +655,250 @@ func (r *LocalRuntime) CurrentAgentTools(ctx context.Context) ([]tools.Tool, err
return a.Tools(ctx)
}
+// ToolsetState is the coarse lifecycle bucket the agent inspector renders as a
+// status glyph. It collapses the full lifecycle.State machine into the three
+// distinctions a reader cares about: serving (started), not running (stopped),
+// or broken (error).
+type ToolsetState string
+
+const (
+ ToolsetStarted ToolsetState = "started"
+ ToolsetStopped ToolsetState = "stopped"
+ ToolsetError ToolsetState = "error"
+)
+
+// ToolsetDetail describes one configured toolset for the agent inspector: its
+// display name, kind, lifecycle bucket and the tools it exposes. Tools holds
+// the live tool names when the toolset is started, otherwise the declared
+// `tools:` allow-list from the retained config, and is empty when neither is
+// available (a not-yet-started toolset with no explicit allow-list).
+type ToolsetDetail struct {
+ Name string
+ Kind string
+ State ToolsetState
+ Tools []string
+}
+
+// AgentConfigInfo is the static-plus-live dataset behind the read-only agent
+// inspector modal. The static parts (sub-agents, handoffs, fallbacks, skills,
+// limits, option flags, declared toolset allow-lists) are derived from the
+// resolved *agent.Agent and the retained config without starting any toolset;
+// the live parts (per-toolset lifecycle state, started tool names, IsCurrent)
+// reflect the running team. Remote runtimes (which hold no local team) return
+// the zero value, so the modal omits every config-derived section.
+type AgentConfigInfo struct {
+ SubAgents []string // sub-agent names, sorted
+ Handoffs []string // handoff target names, sorted
+ Fallbacks []string // fallback model ids ("provider/model"), in priority order
+ Skills []string // configured skill names (inline + included + used), sorted
+
+ MaxIterations int // 0 when unset
+ NumHistoryItems int // 0 when unset
+ MaxConsecutiveToolCalls int // 0 when unset
+
+ Options []string // enabled option flags (add-date, redact-secrets, ...)
+ Toolsets []ToolsetDetail // per-toolset live state + tools, in declaration order
+
+ IsCurrent bool // true when this is the live current agent
+}
+
+// AgentConfigInfo returns the named agent's inspector dataset. It inspects the
+// resolved agent and the retained config, reading live tool names only from
+// already-started toolsets (never starting one), so it is safe to call for any
+// agent whether or not it has run. Unknown agents yield the zero value, so the
+// modal omits the corresponding sections.
+func (r *LocalRuntime) AgentConfigInfo(agentName string) AgentConfigInfo {
+ a, err := r.team.Agent(agentName)
+ if err != nil || a == nil {
+ return AgentConfigInfo{}
+ }
+
+ cfg, hasCfg := r.team.AgentConfig(agentName)
+
+ var subAgents []string
+ for _, sub := range a.SubAgents() {
+ if sub != nil {
+ subAgents = append(subAgents, sub.Name())
+ }
+ }
+
+ var handoffs []string
+ for _, h := range a.Handoffs() {
+ if h != nil {
+ handoffs = append(handoffs, h.Name())
+ }
+ }
+
+ var fallbacks []string
+ for _, p := range a.FallbackModels() {
+ if p != nil {
+ fallbacks = append(fallbacks, p.ID().String())
+ }
+ }
+
+ info := AgentConfigInfo{
+ SubAgents: uniqueNames(subAgents, true),
+ Handoffs: uniqueNames(handoffs, true),
+ Fallbacks: uniqueNames(fallbacks, false),
+ MaxIterations: a.MaxIterations(),
+ NumHistoryItems: a.NumHistoryItems(),
+ MaxConsecutiveToolCalls: a.MaxConsecutiveToolCalls(),
+ Options: agentOptionFlags(a, cfg, hasCfg),
+ Toolsets: toolsetDetails(a, cfg, hasCfg),
+ IsCurrent: r.agents != nil && r.agents.Name() == agentName,
+ }
+ if hasCfg {
+ info.Skills = configSkillNames(cfg)
+ }
+ return info
+}
+
+// agentOptionFlags lists the agent's enabled boolean options as stable,
+// hyphenated display names. AddDate/AddEnvironmentInfo/RedactSecrets read the
+// agent's effective (resolved) values; CodeModeTools is only knowable from the
+// retained config because the runtime folds it into a single wrapper toolset.
+func agentOptionFlags(a *agent.Agent, cfg latest.AgentConfig, hasCfg bool) []string {
+ var opts []string
+ if a.AddDate() {
+ opts = append(opts, "add-date")
+ }
+ if a.AddEnvironmentInfo() {
+ opts = append(opts, "add-environment-info")
+ }
+ if a.RedactSecrets() {
+ opts = append(opts, "redact-secrets")
+ }
+ if hasCfg && cfg.CodeModeTools {
+ opts = append(opts, "code-mode-tools")
+ }
+ return opts
+}
+
+// configSkillNames lists the cleanly-resolvable skill names from the agent
+// config: inline skill names, the Include allow-list, and referenced skill
+// groups (UseSkills). Skills auto-discovered from Sources (e.g. a local
+// directory) are not enumerated here because doing so would require loading
+// them from disk; the inspector notes this rather than starting that work.
+func configSkillNames(cfg latest.AgentConfig) []string {
+ var names []string
+ for _, s := range cfg.Skills.Inline {
+ names = append(names, s.Name)
+ }
+ names = append(names, cfg.Skills.Include...)
+ names = append(names, cfg.UseSkills...)
+ return uniqueNames(names, true)
+}
+
+// toolsetDetails builds one ToolsetDetail per live toolset of the agent,
+// combining the side-effect-free lifecycle status with tool names: live names
+// for started toolsets, otherwise the declared `tools:` allow-list keyed by the
+// same name the registry assigns (cmp.Or(name, type)).
+func toolsetDetails(a *agent.Agent, cfg latest.AgentConfig, hasCfg bool) []ToolsetDetail {
+ toolSets := a.ToolSets()
+ if len(toolSets) == 0 {
+ return nil
+ }
+ var declared map[string][]string
+ if hasCfg {
+ declared = declaredToolNames(cfg)
+ }
+ infos := make([]ToolsetDetail, 0, len(toolSets))
+ for _, ts := range toolSets {
+ status := toolsetStatusFor(ts)
+ info := ToolsetDetail{
+ Name: status.Name,
+ Kind: status.Kind,
+ State: toolsetStateBucket(status.State),
+ }
+ if live, ok := startedToolNames(ts); ok {
+ info.Tools = live
+ } else if names, ok := declared[status.Name]; ok {
+ info.Tools = names
+ }
+ infos = append(infos, info)
+ }
+ return infos
+}
+
+// toolsetStateBucket collapses the lifecycle state machine into the inspector's
+// three buckets. Failed is the only error state; Stopped (including a
+// not-yet-started toolset) is stopped; everything else (Ready, Degraded,
+// Starting, Restarting) reads as started/serving.
+func toolsetStateBucket(s lifecycle.State) ToolsetState {
+ switch s {
+ case lifecycle.StateFailed:
+ return ToolsetError
+ case lifecycle.StateStopped:
+ return ToolsetStopped
+ default:
+ return ToolsetStarted
+ }
+}
+
+// startedToolNames returns the live tool names of ts, but only when it is a
+// started toolset; the boolean is false for not-yet-started toolsets so the
+// caller can fall back to the declared allow-list. context.TODO is safe here:
+// the toolset is already started, so listing returns its cached tools without
+// a cancellable round-trip.
+func startedToolNames(ts tools.ToolSet) ([]string, bool) {
+ s, ok := tools.As[*tools.StartableToolSet](ts)
+ if !ok || !s.IsStarted() {
+ return nil, false
+ }
+ tl, err := s.Tools(context.TODO())
+ if err != nil {
+ return nil, false
+ }
+ names := make([]string, 0, len(tl))
+ for i := range tl {
+ if tl[i].Name != "" {
+ names = append(names, tl[i].Name)
+ }
+ }
+ return names, true
+}
+
+// declaredToolNames maps each configured toolset's display name to its declared
+// `tools:` allow-list. The key matches the registry's naming
+// (cmp.Or(name, type)) so it lines up with the live toolset's Name. Toolsets
+// with no explicit allow-list (serve all tools) are omitted.
+func declaredToolNames(cfg latest.AgentConfig) map[string][]string {
+ m := make(map[string][]string, len(cfg.Toolsets))
+ for i := range cfg.Toolsets {
+ t := cfg.Toolsets[i]
+ key := cmp.Or(t.Name, t.Type)
+ if key == "" || len(t.Tools) == 0 {
+ continue
+ }
+ m[key] = slices.Clone(t.Tools)
+ }
+ return m
+}
+
+// uniqueNames drops empty entries and de-duplicates names. By default it keeps
+// the first-seen order (meaningful for declaration/priority order); when sorted
+// is true it orders the result case-insensitively instead.
+func uniqueNames(names []string, sorted bool) []string {
+ seen := make(map[string]struct{}, len(names))
+ out := make([]string, 0, len(names))
+ for _, name := range names {
+ if name == "" {
+ continue
+ }
+ if _, dup := seen[name]; dup {
+ continue
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ if sorted {
+ slices.SortFunc(out, func(a, b string) int {
+ return strings.Compare(strings.ToLower(a), strings.ToLower(b))
+ })
+ }
+ return out
+}
+
// CurrentAgentToolsetStatuses returns one ToolsetStatus per toolset of the
// active agent. The list is in declaration order. Toolsets that wrap
// another (StartableToolSet, Multiplexer) are unwrapped so the inner
@@ -669,6 +916,24 @@ func (r *LocalRuntime) CurrentAgentToolsetStatuses() []tools.ToolsetStatus {
return statuses
}
+// AgentToolsetStatuses returns one ToolsetStatus per toolset of the named
+// agent, in declaration order. It mirrors CurrentAgentToolsetStatuses but for
+// an arbitrary agent and is side-effect-free: it reads each toolset's live
+// lifecycle (state, kind, last error, restart count) without starting it.
+// Unknown agents yield nil, so callers degrade gracefully.
+func (r *LocalRuntime) AgentToolsetStatuses(name string) []tools.ToolsetStatus {
+ a, err := r.team.Agent(name)
+ if err != nil || a == nil {
+ return nil
+ }
+ toolSets := a.ToolSets()
+ statuses := make([]tools.ToolsetStatus, 0, len(toolSets))
+ for _, ts := range toolSets {
+ statuses = append(statuses, toolsetStatusFor(ts))
+ }
+ return statuses
+}
+
// RestartToolset locates the named toolset on the active agent and
// asks it to restart in place. The supervisor closes the current
// session and reconnects; this method blocks until the new session
@@ -961,8 +1226,10 @@ func (r *LocalRuntime) agentDetailsFromTeam(ctx context.Context) []AgentDetails
// agentThinkingLabel returns a short, user-facing label for the effective
// thinking-effort level of the agent's current model: the effort level (e.g.
-// "high"), "off" when thinking is disabled on a reasoning-capable model, or
-// "" when the model has no selectable thinking configuration to display.
+// "high"), "adaptive" for adaptive budgets, the decimal token count for
+// token-based budgets, "off" when thinking is disabled on a reasoning-capable
+// model, or "" when the model has no selectable thinking configuration to
+// display.
func (r *LocalRuntime) agentThinkingLabel(ctx context.Context, a *agent.Agent) string {
models := a.EffectiveModels()
if len(models) == 0 {
@@ -980,7 +1247,10 @@ func (r *LocalRuntime) agentThinkingLabel(ctx context.Context, a *agent.Agent) s
if l, ok := budget.EffortLevel(); ok {
return l.String()
}
- return "on" // token-based or adaptive budget
+ if budget.IsAdaptive() {
+ return "adaptive"
+ }
+ return strconv.Itoa(budget.Tokens) // token-based budget
}
// SessionStore returns the session store for browsing/loading past sessions.
diff --git a/pkg/team/team.go b/pkg/team/team.go
index dc1ad87a0..631cc7424 100644
--- a/pkg/team/team.go
+++ b/pkg/team/team.go
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/docker/docker-agent/pkg/agent"
+ "github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/permissions"
)
@@ -14,6 +15,12 @@ import (
type Team struct {
agents []*agent.Agent
permissions *permissions.Checker
+ // agentConfigs holds the raw resolved config for each agent, keyed by
+ // name. It is retained only when the team is built from a config file
+ // (WithAgentConfigs) so surfaces like the agent inspector can show
+ // declared toolset allow-lists, limits and flags. Teams built without it
+ // (e.g. the remote runtime) leave it nil and AgentConfig returns false.
+ agentConfigs map[string]latest.AgentConfig
}
type Opt func(*Team)
@@ -30,6 +37,15 @@ func WithPermissions(checker *permissions.Checker) Opt {
}
}
+// WithAgentConfigs retains the per-agent resolved configs (keyed by agent
+// name) on the team. They are read-only reference data used by inspection
+// surfaces; the runtime continues to operate on the resolved *agent.Agent.
+func WithAgentConfigs(configs map[string]latest.AgentConfig) Opt {
+ return func(t *Team) {
+ t.agentConfigs = configs
+ }
+}
+
func New(opts ...Opt) *Team {
t := &Team{}
for _, opt := range opts {
@@ -68,6 +84,11 @@ func (t *Team) AgentsInfo() []AgentInfo {
id := model.ID()
info.Provider = id.Provider
info.Model = id.Model
+ } else if harnessType := a.HarnessType(); harnessType != "" {
+ // Harness-backed agents have no provider.Provider; surface the
+ // harness type (e.g. "claude-code") as the display model and leave
+ // Thinking empty so no badge/card line is shown.
+ info.Model = harnessType
}
infos = append(infos, info)
}
@@ -131,6 +152,15 @@ func (t *Team) StopToolSets(ctx context.Context) error {
return nil
}
+// AgentConfig returns the raw resolved config for the named agent and true
+// when it was retained at construction (WithAgentConfigs). Teams built
+// without configs (e.g. the remote runtime) return the zero value and false,
+// letting callers gracefully omit config-derived detail.
+func (t *Team) AgentConfig(name string) (latest.AgentConfig, bool) {
+ cfg, ok := t.agentConfigs[name]
+ return cfg, ok
+}
+
// Permissions returns the permission checker for this team.
// Returns nil if no permissions are configured.
func (t *Team) Permissions() *permissions.Checker {
diff --git a/pkg/team/team_test.go b/pkg/team/team_test.go
index bd42a81b3..f59452ff7 100644
--- a/pkg/team/team_test.go
+++ b/pkg/team/team_test.go
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/agent"
+ "github.com/docker/docker-agent/pkg/config/latest"
)
func newAgent(name string) *agent.Agent {
@@ -81,3 +82,42 @@ func TestAgentOrDefault(t *testing.T) {
require.Error(t, err)
})
}
+
+// TestAgentConfig verifies the raw per-agent config retained via
+// WithAgentConfigs is returned by name, and that callers can distinguish a
+// team built without configs (remote runtime) from one built with them: both
+// the unknown-agent and no-configs cases report false so the inspector omits
+// config-derived sections.
+func TestAgentConfig(t *testing.T) {
+ t.Parallel()
+
+ configs := map[string]latest.AgentConfig{
+ "root": {Name: "root", Model: "openai/gpt-5", MaxIterations: 42},
+ }
+
+ t.Run("returns retained config by name", func(t *testing.T) {
+ t.Parallel()
+ tm := New(WithAgents(newAgent("root")), WithAgentConfigs(configs))
+
+ cfg, ok := tm.AgentConfig("root")
+ require.True(t, ok)
+ assert.Equal(t, "openai/gpt-5", cfg.Model)
+ assert.Equal(t, 42, cfg.MaxIterations)
+ })
+
+ t.Run("unknown agent returns false", func(t *testing.T) {
+ t.Parallel()
+ tm := New(WithAgents(newAgent("root")), WithAgentConfigs(configs))
+
+ _, ok := tm.AgentConfig("missing")
+ assert.False(t, ok)
+ })
+
+ t.Run("team built without configs returns false", func(t *testing.T) {
+ t.Parallel()
+ tm := New(WithAgents(newAgent("root")))
+
+ _, ok := tm.AgentConfig("root")
+ assert.False(t, ok)
+ })
+}
diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go
index 6babcbdcc..f3ccfe879 100644
--- a/pkg/teamloader/teamloader.go
+++ b/pkg/teamloader/teamloader.go
@@ -313,10 +313,18 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
}
}
+ // Retain the resolved per-agent configs so inspection surfaces (the agent
+ // inspector modal) can show declared toolset allow-lists, limits and flags.
+ agentConfigs := make(map[string]latest.AgentConfig, len(cfg.Agents))
+ for i := range cfg.Agents {
+ agentConfigs[cfg.Agents[i].Name] = cfg.Agents[i]
+ }
+
return &LoadResult{
Team: team.New(
team.WithAgents(agents...),
team.WithPermissions(permChecker),
+ team.WithAgentConfigs(agentConfigs),
),
Models: cfg.Models,
Providers: cfg.Providers,
diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go
index 840dfc3c7..0ebbb4cdc 100644
--- a/pkg/teamloader/teamloader_test.go
+++ b/pkg/teamloader/teamloader_test.go
@@ -651,3 +651,34 @@ agents:
require.Error(t, err)
assert.Contains(t, err.Error(), "escapes parent directory")
}
+
+// TestLoadRetainsAgentConfig verifies the loader retains the raw resolved
+// per-agent config on the team (team.WithAgentConfigs) so the agent inspector
+// can surface declared toolset allow-lists, limits and flags. It uses a
+// built-in toolset (shell) and an openai model so no network access is needed.
+func TestLoadRetainsAgentConfig(t *testing.T) {
+ t.Setenv("OPENAI_API_KEY", "dummy")
+
+ data := []byte(`agents:
+ root:
+ model: openai/gpt-4o
+ instruction: test
+ max_iterations: 7
+ toolsets:
+ - type: shell
+ tools: [shell]
+`)
+
+ team, err := Load(t.Context(), config.NewBytesSource("inspector.yaml", data), &config.RuntimeConfig{})
+ require.NoError(t, err)
+
+ cfg, ok := team.AgentConfig("root")
+ require.True(t, ok, "loader must retain the resolved agent config")
+ assert.Equal(t, 7, cfg.MaxIterations)
+ require.Len(t, cfg.Toolsets, 1)
+ assert.Equal(t, "shell", cfg.Toolsets[0].Type)
+ assert.Equal(t, []string{"shell"}, cfg.Toolsets[0].Tools)
+
+ _, ok = team.AgentConfig("missing")
+ assert.False(t, ok, "unknown agent reports no retained config")
+}
diff --git a/pkg/tui/components/sidebar/agent_click_test.go b/pkg/tui/components/sidebar/agent_click_test.go
index bf5c763e5..3d3cefa8f 100644
--- a/pkg/tui/components/sidebar/agent_click_test.go
+++ b/pkg/tui/components/sidebar/agent_click_test.go
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
@@ -52,3 +53,104 @@ func TestSidebar_HandleClickType_Agent(t *testing.T) {
assert.True(t, foundAgent1, "should be able to click on agent1")
assert.True(t, foundAgent2, "should be able to click on agent2")
}
+
+// TestSidebar_AgentClickZones_EveryRenderedLineMapped verifies that every
+// rendered line an agent emits is registered as a click zone for that agent.
+// The mapping is produced explicitly during rendering (agentLineOwners), so a
+// multi-line agent block is fully clickable, not just its first line.
+func TestSidebar_AgentClickZones_EveryRenderedLineMapped(t *testing.T) {
+ t.Parallel()
+
+ sess := session.New()
+ sessionState := service.NewSessionState(sess)
+ sessionState.SetCurrentAgentName("agent1")
+ sb := New(sessionState)
+
+ m := sb.(*model)
+ m.sessionHasContent = true
+ m.titleGenerated = true
+ m.sessionTitle = "Test"
+ m.currentAgent = "agent1"
+ m.availableAgents = []runtime.AgentDetails{
+ {Name: "agent1", Provider: "openai", Model: "gpt-4", Description: "First agent", Thinking: "high"},
+ {Name: "agent2", Provider: "anthropic", Model: "claude", Description: "Second agent", Thinking: "off"},
+ }
+ m.width = 40
+ m.height = 50
+
+ _ = sb.View()
+
+ // Each agent contributes at least one non-blank owned line.
+ counts := map[string]int{}
+ for _, owner := range m.agentLineOwners {
+ if owner != "" {
+ counts[owner]++
+ }
+ }
+ assert.Positive(t, counts["agent1"], "agent1 should own rendered lines")
+ assert.Positive(t, counts["agent2"], "agent2 should own rendered lines")
+ // agent2 is a non-current roster agent: its row spans two lines (name+badge
+ // then the indented model), and BOTH must map to it so a click on either
+ // switches to the agent.
+ assert.Equal(t, 2, counts["agent2"], "a roster agent owns both of its two row lines")
+
+ // The number of click zones equals the number of owned (non-blank) lines:
+ // every owned line is clickable.
+ owned := 0
+ for _, owner := range m.agentLineOwners {
+ if owner != "" {
+ owned++
+ }
+ }
+ assert.Len(t, m.agentClickZones, owned, "every owned line should be a click zone")
+}
+
+// TestSidebar_BuildAgentClickZones_NoBlankSeparators verifies the click-zone
+// builder relies purely on explicit per-line ownership, not on blank-line
+// separators. A compact roster with one line per agent and no blank lines
+// between them must still map each line to the correct agent.
+func TestSidebar_BuildAgentClickZones_NoBlankSeparators(t *testing.T) {
+ t.Parallel()
+
+ sess := session.New()
+ sessionState := service.NewSessionState(sess)
+ m := New(sessionState).(*model)
+
+ // Simulate a compact roster: three agents, one rendered line each, no
+ // blank separators (the future layout this refactor unblocks).
+ m.agentLineOwners = []string{"agent1", "agent2", "agent3"}
+
+ const agentSectionStart = 5
+ m.buildAgentClickZones(agentSectionStart)
+
+ const tabHeaderLines = 2
+ require.Len(t, m.agentClickZones, 3)
+ assert.Equal(t, "agent1", m.agentClickZones[agentSectionStart+tabHeaderLines+0])
+ assert.Equal(t, "agent2", m.agentClickZones[agentSectionStart+tabHeaderLines+1])
+ assert.Equal(t, "agent3", m.agentClickZones[agentSectionStart+tabHeaderLines+2])
+}
+
+// TestSidebar_BuildAgentClickZones_SkipsBlankOwners verifies that blank
+// separator lines (empty owner) are not registered as click zones, while the
+// surrounding owned lines keep their correct content-line offsets.
+func TestSidebar_BuildAgentClickZones_SkipsBlankOwners(t *testing.T) {
+ t.Parallel()
+
+ sess := session.New()
+ sessionState := service.NewSessionState(sess)
+ m := New(sessionState).(*model)
+
+ // agent1 spans two lines, a blank separator follows, then agent2.
+ m.agentLineOwners = []string{"agent1", "agent1", "", "agent2"}
+
+ const agentSectionStart = 0
+ m.buildAgentClickZones(agentSectionStart)
+
+ const tabHeaderLines = 2
+ require.Len(t, m.agentClickZones, 3)
+ assert.Equal(t, "agent1", m.agentClickZones[tabHeaderLines+0])
+ assert.Equal(t, "agent1", m.agentClickZones[tabHeaderLines+1])
+ _, blankMapped := m.agentClickZones[tabHeaderLines+2]
+ assert.False(t, blankMapped, "blank separator line should not be clickable")
+ assert.Equal(t, "agent2", m.agentClickZones[tabHeaderLines+3])
+}
diff --git a/pkg/tui/components/sidebar/agent_panel_test.go b/pkg/tui/components/sidebar/agent_panel_test.go
new file mode 100644
index 000000000..f0e4bc83f
--- /dev/null
+++ b/pkg/tui/components/sidebar/agent_panel_test.go
@@ -0,0 +1,424 @@
+package sidebar
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/session"
+ "github.com/docker/docker-agent/pkg/tui/service"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// newAgentPanelSidebar builds a sidebar whose current agent and roster are set,
+// ready to render the Agents panel at the given outer width.
+func newAgentPanelSidebar(t *testing.T, current string, width int, agents ...runtime.AgentDetails) *model {
+ t.Helper()
+ sess := session.New()
+ ss := service.NewSessionState(sess)
+ ss.SetCurrentAgentName(current)
+ m := New(ss).(*model)
+ m.sessionHasContent = true
+ m.titleGenerated = true
+ m.sessionTitle = "Test"
+ m.currentAgent = current
+ m.availableAgents = agents
+ m.width = width
+ m.height = 200
+ return m
+}
+
+// renderAgentPanel returns the ANSI-stripped lines of the Agents panel body.
+func renderAgentPanel(m *model) []string {
+ out := ansi.Strip(m.agentInfo(m.contentWidth(false)))
+ return strings.Split(out, "\n")
+}
+
+func TestClassifyThinking(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ wantKind thinkingKind
+ wantTok int64
+ }{
+ {"", thinkingNone, 0},
+ {"off", thinkingOff, 0},
+ {"adaptive", thinkingAdaptive, 0},
+ {"8192", thinkingTokens, 8192},
+ {"high", thinkingLevel, 0},
+ {"minimal", thinkingLevel, 0},
+ }
+ for _, c := range cases {
+ kind, tok := classifyThinking(c.label)
+ assert.Equalf(t, c.wantKind, kind, "kind for %q", c.label)
+ assert.Equalf(t, c.wantTok, tok, "tokens for %q", c.label)
+ }
+}
+
+// TestCardThinkingLine covers every vocabulary case for the focus-card thinking
+// line, including the empty case (line omitted) and the token-budget formatting.
+func TestCardThinkingLine(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ contains string
+ omit bool
+ }{
+ {"high", "thinking " + gaugePattern(4) + " high", false},
+ {"adaptive", "thinking auto adaptive", false},
+ {"8192", "thinking " + styles.TokenGlyph + " 8.2K tokens", false},
+ {"off", "thinking " + gaugePattern(0) + " off", false},
+ {"", "", true},
+ }
+ for _, c := range cases {
+ got := ansi.Strip(cardThinkingLine(c.label))
+ if c.omit {
+ assert.Emptyf(t, got, "label %q should omit the card line", c.label)
+ continue
+ }
+ assert.Equalf(t, c.contains, got, "label %q", c.label)
+ }
+}
+
+// TestCardRenderedInPlace verifies the current agent renders as a focus card at
+// its natural config-order position (not pinned to the top), with the wrapped
+// description, full model id and thinking line, while the agents before it
+// render as compact rows.
+func TestCardRenderedInPlace(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "first", Provider: "openai", Model: "gpt-5.4-mini", Thinking: "off"},
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "claude-opus-4-8", Description: "Executive assistant", Thinking: "high"},
+ runtime.AgentDetails{Name: "last", Provider: "google", Model: "gemini-flash", Thinking: "off"},
+ )
+
+ lines := renderAgentPanel(m)
+
+ // Find the compact row for "first" and the card header for "root".
+ firstRow := -1
+ cardHeader := -1
+ for i, l := range lines {
+ if strings.Contains(l, "first") && strings.Contains(l, "^1") {
+ firstRow = i
+ }
+ if strings.Contains(l, "▶ root") {
+ cardHeader = i
+ }
+ }
+ require.Positive(t, firstRow, "compact row for the first (non-current) agent should render")
+ require.Positive(t, cardHeader, "card header for the current agent should render")
+ assert.Less(t, firstRow, cardHeader, "card must render in place, after the first agent's row")
+
+ body := strings.Join(lines, "\n")
+ assert.Contains(t, body, "Executive assistant", "card shows description")
+ assert.Contains(t, body, "anthropic/claude-opus-4-8", "card shows full provider/model")
+ assert.Contains(t, body, "thinking "+gaugePattern(4)+" high", "card shows gauge+value thinking line")
+}
+
+// TestCardDescriptionWrapsToTwoLinesWithEllipsis verifies a long description is
+// wrapped to at most two lines, with the second line ending in an ellipsis.
+func TestCardDescriptionWrapsToTwoLinesWithEllipsis(t *testing.T) {
+ t.Parallel()
+
+ long := "This is a very long agent description that certainly will not fit on a single sidebar line and must wrap onto a second line and then be truncated"
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "claude-opus-4-8", Description: long, Thinking: "high"},
+ )
+
+ lines := renderAgentPanel(m)
+
+ // Description lines are the wrapped body lines under the card.
+ var descLines []string
+ for _, l := range lines {
+ if strings.Contains(l, "This is a very long") || strings.Contains(l, "that certainly") {
+ descLines = append(descLines, l)
+ }
+ }
+ require.Len(t, descLines, 2, "description must wrap to exactly two lines")
+ assert.Contains(t, descLines[1], "…", "second description line ends with an ellipsis")
+}
+
+// TestHarnessCardNoThinkingLine verifies a harness-backed current agent (empty
+// Thinking, harness type as model) shows the harness type and no thinking line.
+func TestHarnessCardNoThinkingLine(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "slack", 40,
+ runtime.AgentDetails{Name: "slack", Model: "claude-code", Description: "Slack agent", Thinking: ""},
+ )
+
+ body := strings.Join(renderAgentPanel(m), "\n")
+ assert.Contains(t, body, "claude-code", "harness card shows the harness type as the model")
+ assert.NotContains(t, body, "thinking:", "harness card has no thinking line")
+}
+
+// TestRowColumnAlignment verifies roster rows share an aligned badge column and
+// that badges render with the expected vocabulary: effort levels become the
+// fixed-width gauge while token/adaptive/off keep their text badges.
+func TestRowColumnAlignment(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "alpha", Provider: "openai", Model: "gpt-5.4-mini", Thinking: "off"},
+ runtime.AgentDetails{Name: "beta", Provider: "openai", Model: "gpt-5.4", Thinking: "high"},
+ runtime.AgentDetails{Name: "gamma", Provider: "openai", Model: "gpt-4o", Thinking: "8192"},
+ runtime.AgentDetails{Name: "delta", Provider: "google", Model: "gemini", Thinking: "adaptive"},
+ )
+
+ lines := renderAgentPanel(m)
+
+ type rowBadge struct{ line, badge string }
+ var rows []rowBadge
+ for _, l := range lines {
+ switch {
+ case strings.Contains(l, "alpha"):
+ rows = append(rows, rowBadge{l, gaugePattern(0)}) // off → empty gauge
+ case strings.Contains(l, "beta"):
+ rows = append(rows, rowBadge{l, gaugePattern(4)}) // high → six-cell gauge
+ case strings.Contains(l, "gamma"):
+ rows = append(rows, rowBadge{l, styles.TokenGlyph + " 8.2K"})
+ case strings.Contains(l, "delta"):
+ rows = append(rows, rowBadge{l, "auto"})
+ }
+ }
+ require.Len(t, rows, 4, "all four non-current rows should render")
+
+ for _, r := range rows {
+ assert.Truef(t, strings.HasSuffix(strings.TrimRight(r.line, " "), r.badge), "row %q should end with badge %q", r.line, r.badge)
+ }
+
+ // Badges are right-aligned: every row is padded to the same total width, so
+ // the trimmed rows share the same rune length (badges end at one column).
+ end := -1
+ for _, r := range rows {
+ require.GreaterOrEqual(t, strings.LastIndex(r.line, r.badge), 0)
+ w := len([]rune(strings.TrimRight(r.line, " ")))
+ if end == -1 {
+ end = w
+ } else {
+ assert.Equal(t, end, w, "right-aligned badges must end in a single column")
+ }
+ }
+}
+
+// TestRowModelLeftTruncated verifies the model column keeps its informative tail
+// (left-truncation with a leading ellipsis) when it overflows.
+func TestRowModelLeftTruncated(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 30,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "agent2", Provider: "anthropic", Model: "claude-sonnet-4-6", Thinking: "off"},
+ )
+
+ lines := renderAgentPanel(m)
+ var model string
+ for i, l := range lines {
+ if strings.Contains(l, "agent2") && i+1 < len(lines) {
+ model = lines[i+1] // the provider/model is the row's second line
+ }
+ }
+ require.NotEmpty(t, model)
+ assert.Contains(t, model, "…", "overflowing model is left-truncated with an ellipsis")
+ assert.Contains(t, model, "-4-6", "informative model tail survives left-truncation")
+}
+
+// TestRowDigitsRenderTokenBadge verifies a digits-only wire label produces a
+// token badge with the token glyph and formatted count.
+func TestRowDigitsRenderTokenBadge(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "gateway", Provider: "openai", Model: "gpt-5.4", Thinking: "8192"},
+ )
+
+ body := strings.Join(renderAgentPanel(m), "\n")
+ assert.Contains(t, body, styles.TokenGlyph+" 8.2K", "digits label renders a token badge")
+}
+
+// TestHarnessRowNoBadge verifies a harness row shows the harness type as the
+// model and no thinking badge.
+func TestHarnessRowNoBadge(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "slack", Model: "claude-code", Thinking: ""},
+ )
+
+ lines := renderAgentPanel(m)
+ var line1, line2 string
+ for i, l := range lines {
+ if strings.Contains(l, "slack") {
+ line1 = l
+ if i+1 < len(lines) {
+ line2 = lines[i+1]
+ }
+ }
+ }
+ require.NotEmpty(t, line1)
+ assert.Contains(t, line2, "claude-code", "harness row shows the harness type on its model line")
+ assert.NotContains(t, line1, styles.GaugeFilled, "harness row has no thinking gauge")
+ assert.NotContains(t, line1, styles.GaugeEmpty, "harness row has no thinking gauge")
+ assert.NotContains(t, line1, styles.TokenGlyph, "harness row has no token badge")
+}
+
+// TestMoreThanNineAgentsNoShortcutBeyond9 verifies agents past the 9th get no
+// "^N" shortcut hint.
+func TestMoreThanNineAgentsNoShortcutBeyond9(t *testing.T) {
+ t.Parallel()
+
+ agents := []runtime.AgentDetails{
+ {Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ }
+ for i := 2; i <= 12; i++ {
+ agents = append(agents, runtime.AgentDetails{
+ Name: "agent" + string(rune('a'+i)),
+ Provider: "openai",
+ Model: "gpt-4o",
+ Thinking: "off",
+ })
+ }
+ m := newAgentPanelSidebar(t, "root", 40, agents...)
+
+ body := strings.Join(renderAgentPanel(m), "\n")
+ assert.Contains(t, body, "^9", "the 9th agent keeps its shortcut")
+ assert.NotContains(t, body, "^10", "agents beyond the 9th have no shortcut")
+}
+
+// TestDegradationLadder verifies the two-line roster degrades by available
+// width: the provider/model always occupies line 2, while line 1's thinking
+// gauge collapses from the full six-cell gauge to a single cell near MinWidth.
+func TestDegradationLadder(t *testing.T) {
+ t.Parallel()
+
+ makeModel := func(width int) *model {
+ return newAgentPanelSidebar(t, "root", width,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "agent2", Provider: "anthropic", Model: "claude-sonnet-4-6", Thinking: "off"},
+ )
+ }
+
+ linesFor := func(m *model, name string) (line1, line2 string) {
+ ls := renderAgentPanel(m)
+ for i, l := range ls {
+ if strings.Contains(l, name) {
+ line1 = l
+ if i+1 < len(ls) {
+ line2 = ls[i+1]
+ }
+ return line1, line2
+ }
+ }
+ return "", ""
+ }
+
+ // Wide: full empty gauge on line 1, full model on line 2.
+ w1, w2 := linesFor(makeModel(40), "agent2")
+ assert.Contains(t, w1, gaugePattern(0), "wide layout shows the full empty gauge for off")
+ assert.Contains(t, w2, "sonnet-4-6", "wide layout shows the full model on line 2")
+
+ // Near MinWidth (content < rowGlyphOnlyMinWidth): line-1 gauge collapses to a
+ // single cell; the model on line 2 truncates with a leading ellipsis.
+ n1, n2 := linesFor(makeModel(21), "agent2")
+ assert.NotContains(t, n1, gaugePattern(0), "glyph-only layout collapses the full gauge")
+ assert.Contains(t, n1, styles.GaugeEmpty, "glyph-only layout keeps a single gauge cell")
+ assert.Contains(t, n2, "…", "narrow layout left-truncates the model on line 2")
+}
+
+// TestClickZonesCardAndRow verifies that clicking a card line and clicking a row
+// line both resolve to the correct agent.
+func TestClickZonesCardAndRow(t *testing.T) {
+ t.Parallel()
+
+ sess := session.New()
+ ss := service.NewSessionState(sess)
+ ss.SetCurrentAgentName("root")
+ sb := New(ss)
+ m := sb.(*model)
+ m.sessionHasContent = true
+ m.titleGenerated = true
+ m.sessionTitle = "Test"
+ m.currentAgent = "root"
+ m.availableAgents = []runtime.AgentDetails{
+ {Name: "first", Provider: "openai", Model: "gpt-5.4-mini", Thinking: "off"},
+ {Name: "root", Provider: "anthropic", Model: "claude-opus-4-8", Description: "Executive assistant", Thinking: "high"},
+ }
+ m.width = 40
+ m.height = 200
+
+ _ = sb.View()
+
+ paddingLeft := m.layoutCfg.PaddingLeft
+ foundCard := false
+ foundRow := false
+ for y := range len(m.cachedLines) {
+ result, name := sb.HandleClickType(paddingLeft+2, y)
+ if result != ClickAgent {
+ continue
+ }
+ if name == "root" {
+ foundCard = true
+ }
+ if name == "first" {
+ foundRow = true
+ }
+ }
+ assert.True(t, foundCard, "clicking a card line should switch to the card's agent")
+ assert.True(t, foundRow, "clicking a row line should switch to the row's agent")
+}
+
+// TestRosterSeparatesAgentsWithBlankLine verifies a blank separator line is
+// inserted between agent entries (so the two-line rows and the card don't blend
+// together) and that the separator carries an empty owner: a roster agent owns
+// exactly its two content lines, never the separator, so click zones stay
+// aligned.
+func TestRosterSeparatesAgentsWithBlankLine(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Description: "Lead", Thinking: "high"},
+ runtime.AgentDetails{Name: "alpha", Provider: "openai", Model: "gpt-5.4-mini", Thinking: "off"},
+ runtime.AgentDetails{Name: "beta", Provider: "openai", Model: "gpt-5.4", Thinking: "high"},
+ )
+
+ _ = renderAgentPanel(m) // populates agentLineOwners
+
+ // Each non-current roster agent owns exactly its two content lines; the
+ // separators between entries are owned by nobody.
+ counts := map[string]int{}
+ blanks := 0
+ for _, owner := range m.agentLineOwners {
+ if owner == "" {
+ blanks++
+ continue
+ }
+ counts[owner]++
+ }
+ assert.Equal(t, 2, counts["alpha"], "a roster agent owns exactly its two content lines, not the separator")
+ assert.Equal(t, 2, counts["beta"], "a roster agent owns exactly its two content lines, not the separator")
+ assert.Positive(t, blanks, "agents are separated by blank, unowned lines")
+
+ // A blank separator immediately precedes each non-first agent's first owned
+ // line, and no separator leads the panel.
+ require.NotEmpty(t, m.agentLineOwners)
+ assert.NotEmpty(t, m.agentLineOwners[0], "the panel does not start with a separator")
+ alphaStart := -1
+ for i, owner := range m.agentLineOwners {
+ if owner == "alpha" {
+ alphaStart = i
+ break
+ }
+ }
+ require.Positive(t, alphaStart, "alpha should own lines after the card")
+ assert.Empty(t, m.agentLineOwners[alphaStart-1], "a blank separator precedes the alpha entry")
+}
diff --git a/pkg/tui/components/sidebar/effort_gauge_test.go b/pkg/tui/components/sidebar/effort_gauge_test.go
new file mode 100644
index 000000000..842c684e4
--- /dev/null
+++ b/pkg/tui/components/sidebar/effort_gauge_test.go
@@ -0,0 +1,128 @@
+package sidebar
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/tui/components/toolcommon"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// gaugePattern builds the expected ANSI-stripped gauge string for n filled cells
+// of the shared six-cell gauge.
+func gaugePattern(filled int) string {
+ return strings.Repeat(styles.GaugeFilled, filled) +
+ strings.Repeat(styles.GaugeEmpty, toolcommon.EffortGaugeCells-filled)
+}
+
+// TestThinkingBadgeLevelUsesGauge verifies the level case of thinkingBadge
+// renders the shared six-cell gauge (no ✻ glyph) while the glyph-only
+// degradation step returns a single ramp-colored filled cell.
+func TestThinkingBadgeLevelUsesGauge(t *testing.T) {
+ t.Parallel()
+
+ badge, compact := thinkingBadge("high")
+ assert.Equal(t, gaugePattern(4), ansi.Strip(badge), "high renders a 4/6-cell gauge")
+ assert.NotContains(t, ansi.Strip(badge), styles.ThinkingGlyph, "gauge carries no ✻ glyph")
+ assert.Equal(t, styles.GaugeFilled, ansi.Strip(compact), "glyph-only step is a single filled cell")
+}
+
+// TestThinkingBadgeUnknownLevelFallsBackToText verifies an unparseable level
+// label keeps a plain text badge (no glyph) so unknown/future labels still
+// render.
+func TestThinkingBadgeUnknownLevelFallsBackToText(t *testing.T) {
+ t.Parallel()
+
+ badge, compact := thinkingBadge("on")
+ assert.Equal(t, "on", ansi.Strip(badge), "unknown label keeps a plain text badge")
+ assert.NotContains(t, ansi.Strip(badge), styles.ThinkingGlyph, "no ✻ glyph")
+ assert.Equal(t, "on", ansi.Strip(compact))
+}
+
+// TestThinkingBadgeVocabulary verifies the full no-✻ badge vocabulary: none
+// renders nothing, off is an empty gauge, adaptive is "auto", and a token budget
+// keeps its token glyph.
+func TestThinkingBadgeVocabulary(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ badge string
+ compact string
+ }{
+ {"", "", ""},
+ {"off", strings.Repeat(styles.GaugeEmpty, toolcommon.EffortGaugeCells), styles.GaugeEmpty},
+ {"adaptive", "auto", "auto"},
+ {"8192", styles.TokenGlyph + " 8.2K", styles.TokenGlyph},
+ }
+ for _, c := range cases {
+ badge, compact := thinkingBadge(c.label)
+ assert.Equalf(t, c.badge, ansi.Strip(badge), "badge for %q", c.label)
+ assert.Equalf(t, c.compact, ansi.Strip(compact), "compact for %q", c.label)
+ assert.NotContainsf(t, ansi.Strip(badge), styles.ThinkingGlyph, "badge for %q must carry no ✻", c.label)
+ }
+}
+
+// TestCardThinkingLineShowsGaugeAndValue verifies the focus card thinking line
+// is "thinking " (no ✻): both the gauge and the descriptive word.
+func TestCardThinkingLineShowsGaugeAndValue(t *testing.T) {
+ t.Parallel()
+
+ got := ansi.Strip(cardThinkingLine("high"))
+ assert.Equal(t, "thinking "+gaugePattern(4)+" high", got)
+ assert.NotContains(t, got, styles.ThinkingGlyph, "card line carries no ✻ glyph")
+
+ // off shows a dim empty gauge plus the word "off".
+ gotOff := ansi.Strip(cardThinkingLine("off"))
+ assert.Equal(t, "thinking "+strings.Repeat(styles.GaugeEmpty, toolcommon.EffortGaugeCells)+" off", gotOff)
+
+ // Empty label omits the line entirely.
+ assert.Empty(t, ansi.Strip(cardThinkingLine("")))
+}
+
+// TestRowGaugeColumnAlignment verifies a roster of effort-level agents renders
+// fixed-width six-cell gauges on line 1 that all end in the same right-aligned
+// column.
+func TestRowGaugeColumnAlignment(t *testing.T) {
+ t.Parallel()
+
+ m := newAgentPanelSidebar(t, "root", 40,
+ runtime.AgentDetails{Name: "root", Provider: "anthropic", Model: "opus", Thinking: "high"},
+ runtime.AgentDetails{Name: "alpha", Provider: "openai", Model: "gpt-5.4-mini", Thinking: "minimal"},
+ runtime.AgentDetails{Name: "beta", Provider: "openai", Model: "gpt-5.4", Thinking: "medium"},
+ runtime.AgentDetails{Name: "gamma", Provider: "openai", Model: "gpt-4o", Thinking: "max"},
+ )
+
+ lines := renderAgentPanel(m)
+
+ wantGauge := map[string]string{
+ "alpha": gaugePattern(1),
+ "beta": gaugePattern(3),
+ "gamma": gaugePattern(6),
+ }
+ seen := 0
+ end := -1
+ for _, l := range lines {
+ for name, gauge := range wantGauge {
+ // The name sits on line 1; the model (line 2) never contains it.
+ if !strings.Contains(l, name) {
+ continue
+ }
+ seen++
+ trimmed := strings.TrimRight(l, " ")
+ assert.Truef(t, strings.HasSuffix(trimmed, gauge), "row %q should end with gauge %q", trimmed, gauge)
+ w := len([]rune(trimmed))
+ if end == -1 {
+ end = w
+ } else {
+ assert.Equal(t, end, w, "fixed-width gauges must end in a single column")
+ }
+ }
+ }
+ require.Equal(t, len(wantGauge), seen, "every effort-level row should render")
+}
diff --git a/pkg/tui/components/sidebar/layout.go b/pkg/tui/components/sidebar/layout.go
index e807fb9a0..e93fa225d 100644
--- a/pkg/tui/components/sidebar/layout.go
+++ b/pkg/tui/components/sidebar/layout.go
@@ -7,6 +7,19 @@ const (
// treePrefixWidth is the width of tree-structure prefixes like "├ " and "└ ".
treePrefixWidth = 2
+ // rowShortcutWidth is the fixed width of a roster row's leading
+ // shortcut/spinner cell ("^N" or a spinner frame) plus its trailing space.
+ rowShortcutWidth = 3
+
+ // rowIndentWidth is the indent of a roster row's second line (the
+ // provider/model), aligning the model under the agent name on line 1.
+ rowIndentWidth = 3
+
+ // rowGlyphOnlyMinWidth is the content-column breakpoint (near MinWidth) below
+ // which a roster row's line-1 thinking gauge collapses to a single cell so
+ // the name column keeps usable room. The model always stays on line 2.
+ rowGlyphOnlyMinWidth = 22
+
// starClickWidth is the clickable area width for the star indicator.
starClickWidth = 2
diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go
index 9ae220061..388a76735 100644
--- a/pkg/tui/components/sidebar/sidebar.go
+++ b/pkg/tui/components/sidebar/sidebar.go
@@ -15,8 +15,8 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
+ "github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/paths"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
@@ -159,6 +159,11 @@ type model struct {
// Agent click zones: maps content line index to agent name for click detection
agentClickZones map[int]string // content line -> agent name
+ // agentLineOwners records, per rendered agent-section body line, which agent
+ // emitted it (empty for blank separators). It is produced during agentInfo
+ // rendering so click zones can be registered explicitly rather than inferred
+ // from blank-line heuristics.
+ agentLineOwners []string
}
// New creates a new sidebar bound to the given session state.
@@ -498,16 +503,6 @@ func (m *model) ResetStreamTracking() {
m.invalidateCache()
}
-// formatTokenCount formats a token count with K/M suffixes for readability
-func formatTokenCount(count int64) string {
- if count >= 1000000 {
- return fmt.Sprintf("%.1fM", float64(count)/1000000)
- } else if count >= 1000 {
- return fmt.Sprintf("%.1fK", float64(count)/1000)
- }
- return strconv.FormatInt(count, 10)
-}
-
func formatCost(cost float64) string {
return fmt.Sprintf("%.2f", cost)
}
@@ -979,7 +974,7 @@ func (m *model) renderSections(contentWidth int) []string {
// Track where agent entries start so we can detect clicks on agent names
agentSectionStart := len(lines)
appendSection(m.agentInfo(contentWidth))
- m.buildAgentClickZones(agentSectionStart, lines)
+ m.buildAgentClickZones(agentSectionStart)
appendSection(m.toolsetInfo(contentWidth))
@@ -1110,7 +1105,7 @@ func (m *model) computeUsageStats() usageStats {
func (m *model) tokenUsage(contentWidth int) string {
s := m.computeUsageStats()
- line := formatTokenCount(s.tokens)
+ line := styles.MutedStyle.Render(styles.TokenGlyph+" ") + toolcommon.FormatTokenCount(s.tokens)
if s.contextPct != "" {
line += " (" + s.contextPct + ")"
}
@@ -1130,7 +1125,7 @@ func (m *model) tokenUsageSummary() string {
s := m.computeUsageStats()
- parts := []string{"Tokens: " + formatTokenCount(s.tokens)}
+ parts := []string{"Tokens: " + toolcommon.FormatTokenCount(s.tokens)}
if s.sessionCount > 1 {
if s.contextPct != "" {
parts = append(parts, "Context: "+s.contextPct)
@@ -1203,7 +1198,14 @@ func (m *model) queueSection(contentWidth int) string {
return m.renderTab(title, strings.Join(lines, "\n"), contentWidth)
}
-// agentInfo renders the current agent information
+// agentInfo renders the Agents panel: the current agent as a focus card in its
+// natural config-order position, and every other agent as a compact two-line
+// roster row, with a blank separator line between entries so the two-line rows
+// and the card don't blend together. It records which body line belongs to
+// which agent (agentLineOwners) so click zones can be registered explicitly
+// (see buildAgentClickZones) rather than re-derived from blank-line heuristics;
+// each separator carries an empty owner so it stays unclickable and never
+// shifts an agent's click zone.
func (m *model) agentInfo(contentWidth int) string {
// Read current agent from session state so sidebar updates when agent is switched
currentAgent := m.sessionState.CurrentAgentName()
@@ -1219,100 +1221,320 @@ func (m *model) agentInfo(contentWidth int) string {
agentTitle += " ↔"
}
- var content strings.Builder
+ rl := m.computeRosterLayout(contentWidth, currentAgent)
+
+ var bodyLines []string
+ var owners []string
+ add := func(line, owner string) {
+ bodyLines = append(bodyLines, line)
+ owners = append(owners, owner)
+ }
+
for i, agent := range m.availableAgents {
- if content.Len() > 0 {
- content.WriteString("\n\n")
+ // Separate every agent entry with a blank line so the two-line rows and
+ // the focus card stay visually distinct. The separator carries an empty
+ // owner so it is never attributed to an agent or made clickable.
+ if len(bodyLines) > 0 {
+ add("", "")
+ }
+ if agent.Name == currentAgent {
+ for _, line := range m.renderAgentCard(agent, i, contentWidth) {
+ add(line, agent.Name)
+ }
+ continue
+ }
+ for _, line := range m.renderAgentRow(agent, i, rl) {
+ add(line, agent.Name)
}
- isCurrent := agent.Name == currentAgent
- m.renderAgentEntry(&content, agent, isCurrent, i, contentWidth)
}
- return m.renderTab(agentTitle, content.String(), contentWidth)
+ m.agentLineOwners = owners
+
+ return m.renderTab(agentTitle, strings.Join(bodyLines, "\n"), contentWidth)
+}
+
+// thinkingKind classifies an agent's raw thinking wire label into the badge
+// vocabulary used by the card and roster rows.
+type thinkingKind int
+
+const (
+ thinkingNone thinkingKind = iota // empty label: no badge / no card line
+ thinkingOff // "off": disabled on a capable model
+ thinkingAdaptive // "adaptive": adaptive budget
+ thinkingTokens // decimal token count
+ thinkingLevel // effort level word (e.g. "high")
+)
+
+// classifyThinking maps a raw wire label to its kind. For token budgets it also
+// returns the parsed token count.
+func classifyThinking(label string) (thinkingKind, int64) {
+ switch label {
+ case "":
+ return thinkingNone, 0
+ case "off":
+ return thinkingOff, 0
+ case "adaptive":
+ return thinkingAdaptive, 0
+ }
+ if isAllDigits(label) {
+ n, _ := strconv.ParseInt(label, 10, 64)
+ return thinkingTokens, n
+ }
+ return thinkingLevel, 0
}
-func (m *model) renderAgentEntry(content *strings.Builder, agent runtime.AgentDetails, isCurrent bool, index, contentWidth int) {
+func isAllDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+// thinkingBadge returns the styled right-aligned roster badge for an agent's
+// thinking label and the compact single-cell form used in the glyph-only
+// degradation step. Both are empty when the agent has no thinking
+// configuration. The vocabulary carries no ✻ glyph: the effort gauge is the
+// only visual language for thinking.
+func thinkingBadge(label string) (badge, compact string) {
+ kind, tokens := classifyThinking(label)
+ switch kind {
+ case thinkingNone:
+ return "", ""
+ case thinkingOff:
+ // Capable but disabled: a dim empty gauge, distinct from a non-capable
+ // model (which renders nothing). The compact form is a single empty cell.
+ cell := lipgloss.NewStyle().Foreground(styles.TextMuted).Faint(true).Render(styles.GaugeEmpty)
+ return toolcommon.EffortGaugeEmpty(), cell
+ case thinkingAdaptive:
+ auto := styles.ThinkingBadgeStyle.Render("auto")
+ return auto, auto
+ case thinkingTokens:
+ return styles.ThinkingBadgeStyle.Render(styles.TokenGlyph + " " + toolcommon.FormatTokenCount(tokens)),
+ styles.ThinkingBadgeStyle.Render(styles.TokenGlyph)
+ default: // thinkingLevel
+ level, ok := effort.Parse(label)
+ if !ok {
+ // Unknown/future level word: a plain text badge so it still renders.
+ b := styles.ThinkingBadgeStyle.Render(label)
+ return b, b
+ }
+ return toolcommon.EffortGauge(level), toolcommon.EffortFillStyle(level).Render(styles.GaugeFilled)
+ }
+}
+
+// cardThinkingLine returns the focus card's thinking body line
+// "thinking " (no ✻), or "" when the agent has no thinking
+// configuration. The gauge and wording are shared with the agent-details dialog
+// via toolcommon.ThinkingGaugeValue, so all three surfaces speak one language.
+func cardThinkingLine(label string) string {
+ gv := toolcommon.ThinkingGaugeValue(label)
+ if gv == "" {
+ return ""
+ }
+ return styles.MutedStyle.Render("thinking ") + gv
+}
+
+// rosterLayout holds the roster column widths computed once per render so all
+// rows align. Each non-current agent renders on two lines: line 1 is the
+// shortcut + colored name with the thinking badge right-aligned at the content
+// edge; line 2 is the indented provider/model. glyphOnly collapses line 1's
+// badge to a single cell near MinWidth.
+type rosterLayout struct {
+ contentWidth int
+ nameWidth int
+ modelWidth int
+ badgeWidth int
+ glyphOnly bool
+}
+
+// computeRosterLayout derives the roster column widths for the given content
+// width. The two-line layout always keeps the model (on its own line 2), so the
+// only degradation is line 1's badge: below rowGlyphOnlyMinWidth (near MinWidth)
+// the gauge collapses to a single cell to give the name column more room.
+func (m *model) computeRosterLayout(contentWidth int, currentAgent string) rosterLayout {
+ fullBadge, compactBadge := 0, 0
+ for _, a := range m.availableAgents {
+ if a.Name == currentAgent {
+ continue // the current agent renders as the card, not a row
+ }
+ b, c := thinkingBadge(a.Thinking)
+ fullBadge = max(fullBadge, lipgloss.Width(b))
+ compactBadge = max(compactBadge, lipgloss.Width(c))
+ }
+
+ l := rosterLayout{contentWidth: contentWidth, badgeWidth: fullBadge}
+ if contentWidth < rowGlyphOnlyMinWidth {
+ l.glyphOnly = true
+ l.badgeWidth = compactBadge
+ }
+
+ // Line 1: shortcut + name + minGap + right-aligned badge.
+ l.nameWidth = max(1, contentWidth-rowShortcutWidth-minGap-l.badgeWidth)
+ // Line 2: indented provider/model.
+ l.modelWidth = max(1, contentWidth-rowIndentWidth)
+ return l
+}
+
+// rowShortcutCell renders the fixed-width leading cell of a roster row: the
+// spinner frame when the agent is working, the "^N" hint for agents 1–9, or
+// blank padding otherwise.
+func (m *model) rowShortcutCell(agent runtime.AgentDetails, index int) string {
+ switch {
+ case m.workingAgent == agent.Name:
+ frame := styles.AgentAccentStyleFor(agent.Name).Render(m.spinner.RawFrame())
+ return frame + strings.Repeat(" ", max(0, rowShortcutWidth-lipgloss.Width(frame)))
+ case index >= 0 && index < 9:
+ hint := styles.MutedStyle.Render(fmt.Sprintf("^%d", index+1))
+ return hint + strings.Repeat(" ", max(0, rowShortcutWidth-lipgloss.Width(hint)))
+ default:
+ return strings.Repeat(" ", rowShortcutWidth)
+ }
+}
+
+// rightAlign appends padding so hint sits flush against the right edge of width.
+func rightAlign(left, hint string, width int) string {
+ if hint == "" {
+ return left
+ }
+ space := max(1, width-lipgloss.Width(left)-lipgloss.Width(hint))
+ return left + strings.Repeat(" ", space) + hint
+}
+
+// renderAgentRow renders a non-current agent as two lines: line 1 is the
+// shortcut (or spinner) + colored name with the thinking badge right-aligned at
+// the content edge; line 2 is the indented provider/model (or harness type).
+// Harness and non-reasoning agents simply have no badge on line 1. Both lines
+// are owned by the agent so a click on either switches to it.
+func (m *model) renderAgentRow(agent runtime.AgentDetails, index int, l rosterLayout) []string {
+ agentStyle := styles.AgentAccentStyleFor(agent.Name)
+ shortcut := m.rowShortcutCell(agent, index)
+
+ name := toolcommon.TruncateText(agent.Name, l.nameWidth)
+ badge, compact := thinkingBadge(agent.Thinking)
+ if l.glyphOnly {
+ badge = compact
+ }
+ line1 := rightAlign(shortcut+agentStyle.Render(name), badge, l.contentWidth)
+
+ modelText := agent.Model
+ if agent.Provider != "" {
+ modelText = agent.Provider + "/" + agent.Model
+ }
+ model := toolcommon.TruncateTextLeft(modelText, l.modelWidth)
+ line2 := strings.Repeat(" ", rowIndentWidth) + styles.MutedStyle.Render(model)
+
+ return []string{line1, line2}
+}
+
+// renderAgentCard renders the multi-line focus card for the current agent.
+func (m *model) renderAgentCard(agent runtime.AgentDetails, index, contentWidth int) []string {
agentStyle := styles.AgentAccentStyleFor(agent.Name)
var prefix string
- if isCurrent {
- if m.workingAgent == agent.Name {
- prefix = agentStyle.Render(m.spinner.RawFrame()) + " "
- } else {
- prefix = agentStyle.Render("▶") + " "
- }
+ if m.workingAgent == agent.Name {
+ prefix = agentStyle.Render(m.spinner.RawFrame()) + " "
+ } else {
+ prefix = agentStyle.Render("▶") + " "
}
- // Agent name
- agentNameText := prefix + agentStyle.Render(agent.Name)
- // Shortcut hint (^1, ^2, etc.) - show for agents 1-9
- var shortcutHint string
+ var hint 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 := max(contentWidth-nameWidth-hintWidth, 1)
- if shortcutHint != "" {
- content.WriteString(agentNameText + strings.Repeat(" ", spaceWidth) + shortcutHint)
- } else {
- content.WriteString(agentNameText)
+ hint = styles.MutedStyle.Render(fmt.Sprintf("^%d", index+1))
+ }
+ header := rightAlign(prefix+agentStyle.Render(agent.Name), hint, contentWidth)
+
+ bodyWidth := contentWidth - treePrefixWidth
+ var nodes [][]string
+ if desc := wrapDescription(agent.Description, bodyWidth, 2); len(desc) > 0 {
+ nodes = append(nodes, desc)
+ }
+ modelText := agent.Model
+ if agent.Provider != "" {
+ modelText = agent.Provider + "/" + agent.Model
+ }
+ if modelText != "" {
+ nodes = append(nodes, []string{toolcommon.TruncateTextLeft(modelText, bodyWidth)})
+ }
+ if line := cardThinkingLine(agent.Thinking); line != "" {
+ nodes = append(nodes, []string{line})
}
- maxWidth := contentWidth - treePrefixWidth
+ lines := []string{header}
+ lines = append(lines, renderTreeNodes(nodes)...)
+ return lines
+}
- if desc := agent.Description; desc != "" {
- content.WriteString("\n")
- content.WriteString(styles.MutedStyle.Render("├ "))
- content.WriteString(toolcommon.TruncateText(desc, maxWidth))
+// renderTreeNodes renders body nodes with tree-structure prefixes. Each node is
+// one or more already-styled lines; the last node uses "└ ", earlier nodes use
+// "├ ", and continuation lines use "│ " (or blank under the last node).
+func renderTreeNodes(nodes [][]string) []string {
+ var out []string
+ for ni, node := range nodes {
+ last := ni == len(nodes)-1
+ for li, line := range node {
+ var prefix string
+ switch {
+ case li == 0 && last:
+ prefix = "└ "
+ case li == 0:
+ prefix = "├ "
+ case last:
+ prefix = " "
+ default:
+ prefix = "│ "
+ }
+ out = append(out, styles.MutedStyle.Render(prefix)+line)
+ }
}
+ return out
+}
- // Collapse provider, model, and thinking into a single line:
- // "/ • ".
- modelLine := agent.Model
- if agent.Provider != "" {
- modelLine = agent.Provider + "/" + agent.Model
+// wrapDescription wraps plain description text to at most maxLines lines within
+// width, appending an ellipsis to the final line when content is dropped.
+func wrapDescription(desc string, width, maxLines int) []string {
+ if desc == "" || width <= 0 || maxLines <= 0 {
+ return nil
}
- if agent.Thinking != "" {
- modelLine += " • " + agent.Thinking
+ wrapped := toolcommon.WrapLinesWords(desc, width)
+ if len(wrapped) <= maxLines {
+ return wrapped
}
- content.WriteString("\n")
- content.WriteString(styles.MutedStyle.Render("└ "))
- content.WriteString(toolcommon.TruncateText(modelLine, maxWidth))
+ wrapped = wrapped[:maxLines]
+ wrapped[maxLines-1] = ellipsizePlain(wrapped[maxLines-1], width)
+ return wrapped
}
-// isVisuallyBlank returns true if a rendered line contains no visible content.
-// Lines may contain ANSI escape codes and whitespace padding from lipgloss styles
-// (e.g., TabStyle.Width()), so we strip ANSI sequences and check for whitespace.
-func isVisuallyBlank(line string) bool {
- return strings.TrimSpace(ansi.Strip(line)) == ""
+// ellipsizePlain shortens plain text to width and guarantees a trailing ellipsis
+// to signal dropped content.
+func ellipsizePlain(s string, width int) string {
+ if width <= 1 {
+ return "…"
+ }
+ r := []rune(s)
+ for lipgloss.Width(string(r)) > width-1 {
+ r = r[:len(r)-1]
+ }
+ return strings.TrimRight(string(r), " ") + "…"
}
-// buildAgentClickZones populates agentClickZones by scanning the rendered lines
-// to find which lines belong to which agent. It relies on the structure produced
-// by renderTab + agentInfo: a 2-line tab header, then agent blocks separated by
-// visually blank lines. Each consecutive run of non-blank lines maps to the next
-// agent in order. This avoids duplicating line-count logic from renderAgentEntry.
-func (m *model) buildAgentClickZones(agentSectionStart int, lines []string) {
+// buildAgentClickZones populates agentClickZones from the explicit per-line
+// ownership recorded by agentInfo while rendering. agentSectionStart is the
+// index of the agent section's first rendered line; the renderTab wrapper adds
+// a fixed 2-line header (tab title + TabStyle top padding) before the body, so
+// body line j maps to content line agentSectionStart+tabHeaderLines+j. Lines
+// with no owner (blank separators) are not registered.
+func (m *model) buildAgentClickZones(agentSectionStart int) {
m.agentClickZones = make(map[int]string)
- if len(m.availableAgents) == 0 {
- return
- }
const tabHeaderLines = 2 // tab title + TabStyle top padding
- agentIdx := 0
- inBlock := false
-
- for i := agentSectionStart + tabHeaderLines; i < len(lines) && agentIdx < len(m.availableAgents); i++ {
- if isVisuallyBlank(lines[i]) {
- // Blank line: if we were inside a block, advance to the next agent
- if inBlock {
- agentIdx++
- inBlock = false
- }
+ for j, owner := range m.agentLineOwners {
+ if owner == "" {
continue
}
- inBlock = true
- m.agentClickZones[i] = m.availableAgents[agentIdx].Name
+ m.agentClickZones[agentSectionStart+tabHeaderLines+j] = owner
}
}
diff --git a/pkg/tui/components/sidebar/token_usage_test.go b/pkg/tui/components/sidebar/token_usage_test.go
index 2a5e42ecd..de911a547 100644
--- a/pkg/tui/components/sidebar/token_usage_test.go
+++ b/pkg/tui/components/sidebar/token_usage_test.go
@@ -3,11 +3,13 @@ package sidebar
import (
"testing"
+ "github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/tui/service"
+ "github.com/docker/docker-agent/pkg/tui/styles"
)
func TestActiveSessionTokens_SingleSession(t *testing.T) {
@@ -210,3 +212,17 @@ func TestTokenUsageSummary_Empty(t *testing.T) {
assert.Empty(t, m.tokenUsageSummary())
}
+
+// TestTokenUsageTab_ShowsTokenGlyph verifies the vertical Token Usage tab line
+// is prefixed with the shared token glyph (◉).
+func TestTokenUsageTab_ShowsTokenGlyph(t *testing.T) {
+ t.Parallel()
+
+ m := newTestSidebar()
+ m.startStream("session-1", "root")
+ m.recordUsageTokens("session-1", "root", 5000, 3000)
+
+ out := ansi.Strip(m.tokenUsage(40))
+ assert.Contains(t, out, styles.TokenGlyph)
+ assert.Contains(t, out, "8.0K")
+}
diff --git a/pkg/tui/components/toolcommon/effort_gauge.go b/pkg/tui/components/toolcommon/effort_gauge.go
new file mode 100644
index 000000000..cbab8b026
--- /dev/null
+++ b/pkg/tui/components/toolcommon/effort_gauge.go
@@ -0,0 +1,135 @@
+package toolcommon
+
+import (
+ "image/color"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/docker/docker-agent/pkg/effort"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// EffortGaugeCells is the fixed cell width of the effort gauge. The six cells
+// map one-to-one onto the six selectable effort levels, so the gauge is
+// lossless: the filled-cell count alone identifies the level, and the color
+// ramp is a secondary cue.
+const EffortGaugeCells = 6
+
+// effortCells maps each selectable effort level to its filled-cell count. The
+// mapping is lossless (six levels onto six cells).
+var effortCells = map[effort.Level]int{
+ effort.Minimal: 1,
+ effort.Low: 2,
+ effort.Medium: 3,
+ effort.High: 4,
+ effort.XHigh: 5,
+ effort.Max: 6,
+}
+
+// EffortFillStyle returns the foreground style for a level's filled cells: a
+// low-to-high ramp (muted → accent → highlight → success → warning) kept as a
+// secondary cue on top of the now-lossless cell count. Colors come from the
+// active theme so the gauge stays theme-aware.
+func EffortFillStyle(level effort.Level) lipgloss.Style {
+ var c color.Color
+ switch level {
+ case effort.Minimal:
+ c = styles.TextMuted
+ case effort.Low:
+ c = styles.Accent
+ case effort.Medium:
+ c = styles.Highlight
+ case effort.High, effort.XHigh:
+ c = styles.Success
+ case effort.Max:
+ c = styles.Warning
+ default:
+ c = styles.TextMuted
+ }
+ return lipgloss.NewStyle().Foreground(c)
+}
+
+// effortEmptyStyle is the faint style used for unfilled gauge cells.
+func effortEmptyStyle() lipgloss.Style {
+ return lipgloss.NewStyle().Foreground(styles.TextMuted).Faint(true)
+}
+
+// EffortGauge renders the fixed-width six-cell gauge for an effort level: the
+// filled cells take the level's ramp color, the rest are faint. The width is
+// constant across levels so the badge column stays aligned wherever the gauge
+// is shown (roster rows, focus card, agent-details dialog).
+func EffortGauge(level effort.Level) string {
+ filled := effortCells[level]
+ fillStyle := EffortFillStyle(level)
+ emptyStyle := effortEmptyStyle()
+ var b strings.Builder
+ for i := range EffortGaugeCells {
+ if i < filled {
+ b.WriteString(fillStyle.Render(styles.GaugeFilled))
+ } else {
+ b.WriteString(emptyStyle.Render(styles.GaugeEmpty))
+ }
+ }
+ return b.String()
+}
+
+// EffortGaugeEmpty renders a fully empty, faint six-cell gauge. It marks a model
+// that is capable of thinking but has it switched off ("off"), reading as
+// "capable but disabled" — distinct from a non-capable model, which shows no
+// gauge at all.
+func EffortGaugeEmpty() string {
+ return strings.Repeat(effortEmptyStyle().Render(styles.GaugeEmpty), EffortGaugeCells)
+}
+
+// ThinkingMarker returns the compact visual marker for a thinking wire label,
+// used in the gauge column of the focus card and the agent-details dialog:
+//
+// - an effort level → the six-cell effort gauge (colored)
+// - "off" → a dim empty gauge
+// - "adaptive" → the muted word "auto"
+// - a token budget → the token glyph "◉"
+// - an empty label → "" (no marker)
+//
+// Unknown/future level words yield "" so the descriptive value still carries
+// the meaning.
+func ThinkingMarker(label string) string {
+ switch label {
+ case "":
+ return ""
+ case "off":
+ return EffortGaugeEmpty()
+ case "adaptive":
+ return styles.MutedStyle.Render("auto")
+ }
+ if isAllDigits(label) {
+ return styles.MutedStyle.Render(styles.TokenGlyph)
+ }
+ if level, ok := effort.Parse(label); ok {
+ return EffortGauge(level)
+ }
+ return ""
+}
+
+// ThinkingGaugeValue renders the shared " " thinking
+// summary used by the sidebar focus card and the agent-details dialog: the
+// effort gauge (or compact marker) followed by the human-readable description
+// from ThinkingDescription. Returns "" when the label carries no thinking
+// configuration. The "off" description is rendered faint to read as disabled.
+func ThinkingGaugeValue(label string) string {
+ desc := ThinkingDescription(label)
+ if desc == "" {
+ return ""
+ }
+ wordStyle := styles.MutedStyle
+ if label == "off" {
+ wordStyle = wordStyle.Faint(true)
+ }
+ word := wordStyle.Render(desc)
+
+ marker := ThinkingMarker(label)
+ if marker == "" {
+ return word
+ }
+ return marker + " " + word
+}
diff --git a/pkg/tui/components/toolcommon/effort_gauge_test.go b/pkg/tui/components/toolcommon/effort_gauge_test.go
new file mode 100644
index 000000000..3775529ba
--- /dev/null
+++ b/pkg/tui/components/toolcommon/effort_gauge_test.go
@@ -0,0 +1,110 @@
+package toolcommon
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/docker/docker-agent/pkg/effort"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// gaugeCounts returns the number of filled and empty cells in a rendered gauge,
+// after stripping ANSI styling.
+func gaugeCounts(s string) (filled, empty int) {
+ plain := ansi.Strip(s)
+ return strings.Count(plain, styles.GaugeFilled), strings.Count(plain, styles.GaugeEmpty)
+}
+
+// TestEffortGaugeLosslessMapping verifies the six effort levels map onto a
+// distinct, monotonically increasing filled-cell count (1..6), so the gauge is
+// lossless and every level reads differently by cell count alone.
+func TestEffortGaugeLosslessMapping(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ level effort.Level
+ filled int
+ }{
+ {effort.Minimal, 1},
+ {effort.Low, 2},
+ {effort.Medium, 3},
+ {effort.High, 4},
+ {effort.XHigh, 5},
+ {effort.Max, 6},
+ }
+ for _, c := range cases {
+ filled, empty := gaugeCounts(EffortGauge(c.level))
+ assert.Equalf(t, c.filled, filled, "filled cells for %s", c.level)
+ assert.Equalf(t, EffortGaugeCells-c.filled, empty, "empty cells for %s", c.level)
+ assert.Equalf(t, EffortGaugeCells, filled+empty, "total cells for %s", c.level)
+ }
+}
+
+// TestEffortGaugeConstantWidth verifies every level (and the empty gauge) render
+// to the same printable cell width, keeping the badge column aligned.
+func TestEffortGaugeConstantWidth(t *testing.T) {
+ t.Parallel()
+
+ for _, l := range []effort.Level{effort.Minimal, effort.Low, effort.Medium, effort.High, effort.XHigh, effort.Max} {
+ assert.Lenf(t, []rune(ansi.Strip(EffortGauge(l))), EffortGaugeCells, "width for %s", l)
+ }
+ assert.Len(t, []rune(ansi.Strip(EffortGaugeEmpty())), EffortGaugeCells, "empty gauge width")
+}
+
+// TestEffortGaugeEmpty verifies the empty gauge is six faint empty cells and no
+// filled cells: "capable but disabled".
+func TestEffortGaugeEmpty(t *testing.T) {
+ t.Parallel()
+
+ filled, empty := gaugeCounts(EffortGaugeEmpty())
+ assert.Zero(t, filled)
+ assert.Equal(t, EffortGaugeCells, empty)
+}
+
+// TestThinkingMarker covers the marker vocabulary used by the card and dialog.
+func TestThinkingMarker(t *testing.T) {
+ t.Parallel()
+
+ // Effort level → full gauge.
+ filled, empty := gaugeCounts(ThinkingMarker("high"))
+ assert.Equal(t, 4, filled)
+ assert.Equal(t, 2, empty)
+
+ // off → empty gauge.
+ filled, empty = gaugeCounts(ThinkingMarker("off"))
+ assert.Zero(t, filled)
+ assert.Equal(t, EffortGaugeCells, empty)
+
+ // adaptive → the word "auto", no gauge.
+ assert.Equal(t, "auto", ansi.Strip(ThinkingMarker("adaptive")))
+
+ // token budget → the token glyph, no gauge.
+ assert.Equal(t, styles.TokenGlyph, ansi.Strip(ThinkingMarker("8192")))
+
+ // empty → no marker.
+ assert.Empty(t, ThinkingMarker(""))
+}
+
+// TestThinkingGaugeValue covers the shared " " summary used
+// by the focus card and the agent-details dialog for every vocabulary case.
+func TestThinkingGaugeValue(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ want string
+ }{
+ {"high", strings.Repeat(styles.GaugeFilled, 4) + strings.Repeat(styles.GaugeEmpty, 2) + " high"},
+ {"off", strings.Repeat(styles.GaugeEmpty, EffortGaugeCells) + " off"},
+ {"adaptive", "auto adaptive"},
+ {"8192", styles.TokenGlyph + " 8.2K tokens"},
+ }
+ for _, c := range cases {
+ assert.Equalf(t, c.want, ansi.Strip(ThinkingGaugeValue(c.label)), "label %q", c.label)
+ }
+
+ assert.Empty(t, ThinkingGaugeValue(""), "empty label yields no summary")
+}
diff --git a/pkg/tui/components/toolcommon/thinking.go b/pkg/tui/components/toolcommon/thinking.go
new file mode 100644
index 000000000..7baac296f
--- /dev/null
+++ b/pkg/tui/components/toolcommon/thinking.go
@@ -0,0 +1,40 @@
+package toolcommon
+
+import "strconv"
+
+// ThinkingDescription returns a human-readable description of a model's
+// thinking-effort wire label, shared by the sidebar focus card and the
+// agent-details dialog so both speak the same vocabulary:
+//
+// - an effort level passes through verbatim (e.g. "high", "minimal")
+// - "adaptive" stays "adaptive"
+// - a decimal token budget becomes " tokens" (e.g. "8.2K tokens")
+// - "off" stays "off"
+// - an empty label yields "" (the model has no thinking configuration)
+func ThinkingDescription(label string) string {
+ switch label {
+ case "":
+ return ""
+ case "off":
+ return "off"
+ case "adaptive":
+ return "adaptive"
+ }
+ if isAllDigits(label) {
+ n, _ := strconv.ParseInt(label, 10, 64)
+ return FormatTokenCount(n) + " tokens"
+ }
+ return label
+}
+
+func isAllDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pkg/tui/components/toolcommon/thinking_test.go b/pkg/tui/components/toolcommon/thinking_test.go
new file mode 100644
index 000000000..e2bc8c3bd
--- /dev/null
+++ b/pkg/tui/components/toolcommon/thinking_test.go
@@ -0,0 +1,27 @@
+package toolcommon
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestThinkingDescription(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ label string
+ want string
+ }{
+ {"", ""},
+ {"off", "off"},
+ {"adaptive", "adaptive"},
+ {"high", "high"},
+ {"minimal", "minimal"},
+ {"8192", "8.2K tokens"},
+ {"500", "500 tokens"},
+ }
+ for _, c := range cases {
+ assert.Equalf(t, c.want, ThinkingDescription(c.label), "label %q", c.label)
+ }
+}
diff --git a/pkg/tui/components/toolcommon/tokencount.go b/pkg/tui/components/toolcommon/tokencount.go
new file mode 100644
index 000000000..29829c119
--- /dev/null
+++ b/pkg/tui/components/toolcommon/tokencount.go
@@ -0,0 +1,21 @@
+package toolcommon
+
+import (
+ "fmt"
+ "strconv"
+)
+
+// FormatTokenCount formats a token count with K/M suffixes for readability
+// (e.g. 8200 → "8.2K", 1500000 → "1.5M"). Values below 1000 are rendered
+// verbatim. This is the canonical implementation shared by the sidebar and
+// the cost/model dialogs.
+func FormatTokenCount(count int64) string {
+ switch {
+ case count >= 1_000_000:
+ return fmt.Sprintf("%.1fM", float64(count)/1_000_000)
+ case count >= 1_000:
+ return fmt.Sprintf("%.1fK", float64(count)/1_000)
+ default:
+ return strconv.FormatInt(count, 10)
+ }
+}
diff --git a/pkg/tui/components/toolcommon/truncate.go b/pkg/tui/components/toolcommon/truncate.go
index 893f660c7..99665283a 100644
--- a/pkg/tui/components/toolcommon/truncate.go
+++ b/pkg/tui/components/toolcommon/truncate.go
@@ -4,6 +4,7 @@ import (
"strings"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
"github.com/docker/docker-agent/pkg/tui/styles"
)
@@ -30,6 +31,40 @@ func TruncateText(text string, maxWidth int) string {
return string(runes[:end]) + "…"
}
+// TruncateTextLeft truncates text from the left to fit within maxWidth, keeping
+// the tail and prepending an ellipsis when truncation occurs. It mirrors
+// [TruncateText]'s edge cases but is the keep-the-tail counterpart: useful for
+// model identifiers where the informative suffix (e.g. "…sonnet-4-6") should
+// survive. Wraps [ansi.TruncateLeft] so ANSI escape sequences and wide
+// characters are handled correctly.
+func TruncateTextLeft(text string, maxWidth int) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+
+ // Fast path: check if text fits without truncation.
+ if lipgloss.Width(text) <= maxWidth {
+ return text
+ }
+
+ if maxWidth == 1 {
+ return "…"
+ }
+
+ // Remove enough leading cells to leave room for the ellipsis prefix.
+ cut := lipgloss.Width(text) - maxWidth + 1
+ out := ansi.TruncateLeft(text, cut, "…")
+
+ // Wide characters cannot be split, so cutting on a wide-char boundary can
+ // leave the result one cell too wide; drop further graphemes until it fits.
+ for lipgloss.Width(out) > maxWidth {
+ cut++
+ out = ansi.TruncateLeft(text, cut, "…")
+ }
+
+ return out
+}
+
func takeRunesThatFit(runes []rune, start, width int) int {
if width <= 0 {
if start < len(runes) {
diff --git a/pkg/tui/components/toolcommon/truncate_test.go b/pkg/tui/components/toolcommon/truncate_test.go
new file mode 100644
index 000000000..71966c932
--- /dev/null
+++ b/pkg/tui/components/toolcommon/truncate_test.go
@@ -0,0 +1,74 @@
+package toolcommon
+
+import (
+ "testing"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTruncateTextLeft(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ text string
+ maxWidth int
+ expected string
+ }{
+ {name: "text within width", text: "hello", maxWidth: 10, expected: "hello"},
+ {name: "text exactly at width", text: "hello", maxWidth: 5, expected: "hello"},
+ {name: "keeps the tail", text: "hello world", maxWidth: 8, expected: "…o world"},
+ {name: "model identifier keeps suffix", text: "claude-sonnet-4-6", maxWidth: 13, expected: "…e-sonnet-4-6"},
+ {name: "truncate to minimum", text: "hello", maxWidth: 2, expected: "…o"},
+ {name: "empty string", text: "", maxWidth: 10, expected: ""},
+ {name: "width of 1 returns ellipsis only", text: "hello", maxWidth: 1, expected: "…"},
+ {name: "zero width", text: "hello", maxWidth: 0, expected: ""},
+ {name: "negative width", text: "hello", maxWidth: -5, expected: ""},
+ {name: "single character fits", text: "a", maxWidth: 1, expected: "a"},
+ {name: "single character with larger width", text: "a", maxWidth: 10, expected: "a"},
+ {name: "unicode within width", text: "héllo", maxWidth: 10, expected: "héllo"},
+ {name: "unicode needs truncation", text: "héllo wörld", maxWidth: 8, expected: "…o wörld"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ result := TruncateTextLeft(tt.text, tt.maxWidth)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestTruncateTextLeft_NeverExceedsWidth guards the wide-character boundary
+// case: cutting on a wide grapheme can momentarily overshoot, so the result
+// must always measure at most maxWidth cells.
+func TestTruncateTextLeft_NeverExceedsWidth(t *testing.T) {
+ t.Parallel()
+
+ inputs := []string{"你好世界", "hello你好世界", "claude-sonnet-4-6", "résumé wörld"}
+ for _, s := range inputs {
+ for w := 1; w <= lipgloss.Width(s)+2; w++ {
+ out := TruncateTextLeft(s, w)
+ assert.LessOrEqualf(t, lipgloss.Width(out), w,
+ "TruncateTextLeft(%q, %d) = %q exceeded width", s, w, out)
+ }
+ }
+}
+
+// TestTruncateTextLeft_PreservesANSI verifies styled input keeps its escape
+// sequences intact and reports the visible (ANSI-stripped) width correctly.
+func TestTruncateTextLeft_PreservesANSI(t *testing.T) {
+ t.Parallel()
+
+ styled := lipgloss.NewStyle().Bold(true).Render("claude-sonnet-4-6")
+ out := TruncateTextLeft(styled, 8)
+
+ assert.LessOrEqual(t, lipgloss.Width(out), 8)
+ stripped := ansi.Strip(out)
+ assert.Contains(t, stripped, "…")
+ assert.Contains(t, stripped, "-4-6", "informative tail should survive")
+ assert.NotEqual(t, stripped, out, "ANSI styling should be preserved in the output")
+}
diff --git a/pkg/tui/dialog/agent_details.go b/pkg/tui/dialog/agent_details.go
new file mode 100644
index 000000000..994d13dea
--- /dev/null
+++ b/pkg/tui/dialog/agent_details.go
@@ -0,0 +1,258 @@
+package dialog
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/tui/components/toolcommon"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// Single-width, non-emoji status glyphs for the toolset section. They mirror
+// the lifecycle buckets in runtime.ToolsetState: serving, not running, broken.
+const (
+ toolsetGlyphStarted = "●"
+ toolsetGlyphStopped = "○"
+ toolsetGlyphError = "⚠"
+)
+
+type agentDetailsDialog struct {
+ readOnlyScrollDialog
+
+ agent runtime.AgentDetails
+ cfg runtime.AgentConfigInfo
+}
+
+// NewAgentDetailsDialog creates the read-only Agent Inspector: an agent's
+// static configuration combined with live state. It shows the description, a
+// "current agent" line when live, model/fallback/thinking, the sub-agent,
+// handoff and skill lists, configured limits and enabled option flags, every
+// toolset with a status marker and its tools (live names when started,
+// otherwise the declared allow-list), and the slash commands it defines. The
+// instruction/system prompt is deliberately omitted. cfg carries the inspector
+// dataset resolved by the caller (pass the zero value to omit those sections,
+// e.g. for remote runtimes). The dialog is opened by right-clicking (or
+// Ctrl+clicking) an agent in the sidebar.
+func NewAgentDetailsDialog(a runtime.AgentDetails, cfg runtime.AgentConfigInfo) Dialog {
+ d := &agentDetailsDialog{agent: a, cfg: cfg}
+ d.readOnlyScrollDialog = newReadOnlyScrollDialog(
+ readOnlyScrollDialogSize{widthPercent: 70, minWidth: 50, maxWidth: 100, heightPercent: 80, heightMax: 40},
+ d.renderLines,
+ )
+ return d
+}
+
+func (d *agentDetailsDialog) renderLines(contentWidth, _ int) []string {
+ // The title is rendered in the agent's own accent color so it matches the
+ // sidebar roster/card, while keeping the dialog title's bold centering.
+ titleStyle := styles.DialogTitleStyle.Foreground(styles.AgentAccentStyleFor(d.agent.Name).GetForeground())
+ lines := []string{
+ RenderTitle(d.agent.Name, contentWidth, titleStyle),
+ RenderSeparator(contentWidth),
+ "",
+ }
+
+ if desc := strings.TrimSpace(d.agent.Description); desc != "" {
+ for _, l := range toolcommon.WrapLinesWords(desc, contentWidth) {
+ lines = append(lines, styles.MutedStyle.Render(l))
+ }
+ lines = append(lines, "")
+ }
+
+ if d.cfg.IsCurrent {
+ marker := styles.SuccessStyle.Render(toolsetGlyphStarted)
+ lines = append(lines, marker+" "+styles.MutedStyle.Render("current agent"), "")
+ }
+
+ lines = append(lines, detailField("Model", d.modelText()))
+ if len(d.cfg.Fallbacks) > 0 {
+ lines = append(lines, detailField("Fallback", strings.Join(d.cfg.Fallbacks, ", ")))
+ }
+ if gv := toolcommon.ThinkingGaugeValue(d.agent.Thinking); gv != "" {
+ lines = append(lines, styles.BoldStyle.Render("Thinking:")+" "+gv)
+ }
+
+ lines = append(lines, d.inlineList(contentWidth, "Sub-agents", d.cfg.SubAgents)...)
+ lines = append(lines, d.inlineList(contentWidth, "Handoffs", d.cfg.Handoffs)...)
+ lines = append(lines, d.inlineList(contentWidth, "Skills", d.cfg.Skills)...)
+
+ if l := d.limitsLine(); l != "" {
+ lines = append(lines, l)
+ }
+ if l := d.optionsLine(); l != "" {
+ lines = append(lines, l)
+ }
+
+ lines = append(lines, d.toolsetLines(contentWidth)...)
+ lines = append(lines, d.commandLines(contentWidth)...)
+
+ return lines
+}
+
+func (d *agentDetailsDialog) modelText() string {
+ if d.agent.Provider != "" && d.agent.Model != "" {
+ return d.agent.Provider + "/" + d.agent.Model
+ }
+ if d.agent.Model != "" {
+ return d.agent.Model
+ }
+ return d.agent.Provider
+}
+
+// inlineList renders a compact "Label (N): a, b, c" summary wrapped to the
+// dialog width, with the bold "Label (N):" prefix. It returns nil when items is
+// empty so the caller omits the line entirely. It is used for the sub-agents,
+// handoffs and skills sections.
+func (d *agentDetailsDialog) inlineList(contentWidth int, label string, items []string) []string {
+ if len(items) == 0 {
+ return nil
+ }
+ prefix := fmt.Sprintf("%s (%d):", label, len(items))
+ full := prefix + " " + strings.Join(items, ", ")
+ wrapped := toolcommon.WrapLinesWords(full, contentWidth)
+ out := make([]string, 0, len(wrapped))
+ for i, l := range wrapped {
+ if i == 0 {
+ out = append(out, styles.BoldStyle.Render(prefix)+styles.MutedStyle.Render(strings.TrimPrefix(l, prefix)))
+ continue
+ }
+ out = append(out, styles.MutedStyle.Render(l))
+ }
+ return out
+}
+
+// limitsLine renders the configured per-agent limits as "Limits: max-iter 50 ·
+// history 40 · max-tool-calls 5", including only the limits that are set
+// (non-zero). It returns "" when none are configured.
+func (d *agentDetailsDialog) limitsLine() string {
+ var parts []string
+ if d.cfg.MaxIterations > 0 {
+ parts = append(parts, fmt.Sprintf("max-iter %d", d.cfg.MaxIterations))
+ }
+ if d.cfg.NumHistoryItems > 0 {
+ parts = append(parts, fmt.Sprintf("history %d", d.cfg.NumHistoryItems))
+ }
+ if d.cfg.MaxConsecutiveToolCalls > 0 {
+ parts = append(parts, fmt.Sprintf("max-tool-calls %d", d.cfg.MaxConsecutiveToolCalls))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return detailField("Limits", strings.Join(parts, " · "))
+}
+
+// optionsLine renders the agent's enabled option flags as "Options: add-date ·
+// redact-secrets · …". It returns "" when no flags are enabled.
+func (d *agentDetailsDialog) optionsLine() string {
+ if len(d.cfg.Options) == 0 {
+ return ""
+ }
+ return detailField("Options", strings.Join(d.cfg.Options, " · "))
+}
+
+// toolsetLines renders the Toolsets section: one header line per toolset with a
+// status marker (● started / ○ stopped / ⚠ error), the name, "(kind)" and a
+// tool count, followed by the indented, wrapped tool names — live names when
+// the toolset is started, or "declared: …" from the allow-list otherwise.
+func (d *agentDetailsDialog) toolsetLines(contentWidth int) []string {
+ if len(d.cfg.Toolsets) == 0 {
+ return nil
+ }
+ lines := []string{"", sectionHeading(fmt.Sprintf("Toolsets (%d)", len(d.cfg.Toolsets)))}
+ for _, ts := range d.cfg.Toolsets {
+ lines = append(lines, toolsetHeaderLine(ts))
+ lines = append(lines, toolsetToolLines(contentWidth, ts)...)
+ }
+ return lines
+}
+
+func toolsetHeaderLine(ts runtime.ToolsetDetail) string {
+ header := toolsetStateGlyph(ts.State) + " " + ts.Name +
+ styles.MutedStyle.Render(" ("+toolsetKindLabel(ts.Kind)+")")
+ if n := len(ts.Tools); n > 0 {
+ header += styles.MutedStyle.Render(" · " + countLabel(n, "tool"))
+ }
+ return header
+}
+
+func toolsetToolLines(contentWidth int, ts runtime.ToolsetDetail) []string {
+ if len(ts.Tools) == 0 {
+ return nil
+ }
+ body := strings.Join(ts.Tools, ", ")
+ if ts.State != runtime.ToolsetStarted {
+ body = "declared: " + body
+ }
+ const indent = " "
+ avail := contentWidth - len(indent)
+ if avail <= 0 {
+ return nil
+ }
+ wrapped := toolcommon.WrapLinesWords(body, avail)
+ out := make([]string, 0, len(wrapped))
+ for _, l := range wrapped {
+ out = append(out, indent+styles.MutedStyle.Render(l))
+ }
+ return out
+}
+
+// toolsetStateGlyph returns the colored status marker for a toolset's lifecycle
+// bucket, reusing the success/warning/muted palette.
+func toolsetStateGlyph(state runtime.ToolsetState) string {
+ switch state {
+ case runtime.ToolsetError:
+ return styles.WarningStyle.Render(toolsetGlyphError)
+ case runtime.ToolsetStopped:
+ return styles.MutedStyle.Render(toolsetGlyphStopped)
+ default:
+ return styles.SuccessStyle.Render(toolsetGlyphStarted)
+ }
+}
+
+func (d *agentDetailsDialog) commandLines(contentWidth int) []string {
+ if len(d.agent.Commands) == 0 {
+ return nil
+ }
+
+ names := make([]string, 0, len(d.agent.Commands))
+ for name := range d.agent.Commands {
+ names = append(names, name)
+ }
+ slices.Sort(names)
+
+ lines := []string{"", sectionHeading(fmt.Sprintf("Commands (%d)", len(names)))}
+ for _, name := range names {
+ label := lipgloss.NewStyle().Foreground(styles.Highlight).Render(" /" + name)
+ lines = append(lines, label)
+ if desc := strings.TrimSpace(d.agent.Commands[name].DisplayText()); desc != "" {
+ indent := " "
+ availableWidth := contentWidth - lipgloss.Width(indent)
+ if availableWidth > 0 {
+ lines = append(lines, indent+styles.MutedStyle.Render(toolcommon.TruncateText(desc, availableWidth)))
+ }
+ }
+ }
+ return lines
+}
+
+// countLabel renders "1 tool" / "3 tools" with naive pluralization (the nouns
+// used here are all regular).
+func countLabel(n int, noun string) string {
+ if n == 1 {
+ return "1 " + noun
+ }
+ return fmt.Sprintf("%d %ss", n, noun)
+}
+
+// detailField renders a "Label: value" body line with a bold label.
+func detailField(label, value string) string {
+ return styles.BoldStyle.Render(label+":") + " " + styles.MutedStyle.Render(value)
+}
+
+func sectionHeading(title string) string {
+ return lipgloss.NewStyle().Bold(true).Foreground(styles.TextSecondary).Render(title)
+}
diff --git a/pkg/tui/dialog/agent_details_test.go b/pkg/tui/dialog/agent_details_test.go
new file mode 100644
index 000000000..dbbdd24e5
--- /dev/null
+++ b/pkg/tui/dialog/agent_details_test.go
@@ -0,0 +1,236 @@
+package dialog
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/docker/docker-agent/pkg/config/types"
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/tui/components/toolcommon"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+// renderAgentDetails returns the ANSI-stripped rendered lines of the dialog
+// body. cfg populates the inline config sections (toolsets, sub-agents,
+// handoffs, fallbacks).
+func renderAgentDetails(a runtime.AgentDetails, cfg runtime.AgentConfigInfo) string {
+ d := NewAgentDetailsDialog(a, cfg).(*agentDetailsDialog)
+ return ansi.Strip(strings.Join(d.renderLines(80, 24), "\n"))
+}
+
+func TestAgentDetailsDialog_RendersCoreFields(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{
+ Name: "root",
+ Description: "Executive assistant that routes work",
+ Provider: "anthropic",
+ Model: "claude-opus-4-8",
+ Thinking: "high",
+ }, runtime.AgentConfigInfo{})
+
+ assert.Contains(t, out, "root")
+ assert.Contains(t, out, "Executive assistant that routes work")
+ assert.Contains(t, out, "Model: anthropic/claude-opus-4-8")
+ // The thinking line shows the same six-cell gauge + value as the sidebar.
+ assert.Contains(t, out, "Thinking: "+ansi.Strip(toolcommon.ThinkingGaugeValue("high")))
+}
+
+// TestAgentDetailsDialog_ThinkingVocabulary covers every thinking label kind,
+// including the empty case (no thinking line). Each non-empty line carries the
+// shared gauge + value rendering.
+func TestAgentDetailsDialog_ThinkingVocabulary(t *testing.T) {
+ t.Parallel()
+
+ for _, thinking := range []string{"high", "adaptive", "8192", "off", ""} {
+ t.Run(thinking, func(t *testing.T) {
+ t.Parallel()
+ out := renderAgentDetails(runtime.AgentDetails{
+ Name: "agent",
+ Provider: "openai",
+ Model: "gpt-5.4",
+ Thinking: thinking,
+ }, runtime.AgentConfigInfo{})
+ if thinking == "" {
+ assert.NotContains(t, out, "Thinking:", "empty thinking label must omit the line")
+ return
+ }
+ assert.Contains(t, out, "Thinking: "+ansi.Strip(toolcommon.ThinkingGaugeValue(thinking)))
+ })
+ }
+}
+
+// TestAgentDetailsDialog_RendersConfigSections verifies the inline compact
+// config summaries — sub-agents, handoffs, skills and the fallback model —
+// render with their counts, and that each section is omitted when its slice is
+// empty.
+func TestAgentDetailsDialog_RendersConfigSections(t *testing.T) {
+ t.Parallel()
+
+ withCfg := renderAgentDetails(runtime.AgentDetails{
+ Name: "root", Provider: "openai", Model: "gpt-5.4", Thinking: "high",
+ }, runtime.AgentConfigInfo{
+ SubAgents: []string{"coder", "reviewer"},
+ Handoffs: []string{"planner"},
+ Skills: []string{"debugging", "refactor"},
+ Fallbacks: []string{"anthropic/claude-opus-4-8"},
+ })
+ assert.Contains(t, withCfg, "Sub-agents (2): coder, reviewer")
+ assert.Contains(t, withCfg, "Handoffs (1): planner")
+ assert.Contains(t, withCfg, "Skills (2): debugging, refactor")
+ assert.Contains(t, withCfg, "Fallback: anthropic/claude-opus-4-8")
+
+ empty := renderAgentDetails(runtime.AgentDetails{Name: "root", Model: "gpt-5.4"}, runtime.AgentConfigInfo{})
+ assert.NotContains(t, empty, "Sub-agents (", "no sub-agents section when none configured")
+ assert.NotContains(t, empty, "Handoffs (", "no handoffs section when none configured")
+ assert.NotContains(t, empty, "Skills (", "no skills section when none configured")
+ assert.NotContains(t, empty, "Toolsets (", "no toolsets section when none configured")
+ assert.NotContains(t, empty, "Limits:", "no limits line when none configured")
+ assert.NotContains(t, empty, "Options:", "no options line when none configured")
+ assert.NotContains(t, empty, "Fallback:", "no fallback line when none configured")
+ assert.NotContains(t, empty, "current agent", "no live line when not current")
+}
+
+// TestAgentDetailsDialog_TitleUsesAgentAccentColor verifies the title is
+// rendered in the agent's accent color (matching the sidebar), so two agents
+// produce differently-colored titles. Not parallel: it mutates the global
+// agent-order registry.
+func TestAgentDetailsDialog_TitleUsesAgentAccentColor(t *testing.T) {
+ styles.SetAgentOrder([]string{"root", "helper"})
+ t.Cleanup(func() { styles.SetAgentOrder(nil) })
+
+ titleOf := func(name string) string {
+ d := NewAgentDetailsDialog(runtime.AgentDetails{Name: name, Model: "gpt"}, runtime.AgentConfigInfo{}).(*agentDetailsDialog)
+ return d.renderLines(80, 24)[0] // raw title line, with ANSI styling
+ }
+
+ root := titleOf("root")
+ helper := titleOf("helper")
+
+ assert.Equal(t, "root", strings.TrimSpace(ansi.Strip(root)))
+ assert.Equal(t, "helper", strings.TrimSpace(ansi.Strip(helper)))
+ assert.NotEqual(t, root, helper, "each agent's title is rendered in its own accent color")
+
+ // The title matches DialogTitleStyle recolored with the agent's accent.
+ want := RenderTitle("root", 80, styles.DialogTitleStyle.Foreground(styles.AgentAccentStyleFor("root").GetForeground()))
+ assert.Equal(t, want, root)
+}
+
+func TestAgentDetailsDialog_RendersCommands(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{
+ Name: "root",
+ Provider: "anthropic",
+ Model: "opus",
+ Thinking: "high",
+ Commands: types.Commands{
+ "plan": {Description: "Hand off to the planner", Agent: "planner"},
+ "fix-lint": {Description: "Fix linting errors"},
+ },
+ }, runtime.AgentConfigInfo{})
+
+ assert.Contains(t, out, "Commands (2)")
+ assert.Contains(t, out, "/fix-lint")
+ assert.Contains(t, out, "Fix linting errors")
+ assert.Contains(t, out, "/plan")
+ assert.Contains(t, out, "Hand off to the planner")
+}
+
+func TestAgentDetailsDialog_HarnessNoThinkingNoCommands(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{
+ Name: "slack",
+ Description: "Slack agent",
+ Model: "claude-code",
+ }, runtime.AgentConfigInfo{})
+
+ assert.Contains(t, out, "slack")
+ assert.Contains(t, out, "Model: claude-code")
+ assert.NotContains(t, out, "Thinking:", "harness agent has no thinking line")
+ assert.NotContains(t, out, "Commands", "no commands section without commands")
+}
+
+// TestAgentDetailsDialog_RendersToolsets verifies the Toolsets section: a
+// status marker per toolset (● started / ○ stopped / ⚠ error), the name, kind
+// and tool count, and the tools rendered as live names when started but as a
+// "declared:" allow-list when stopped.
+func TestAgentDetailsDialog_RendersToolsets(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{Name: "root", Model: "gpt-5.4"}, runtime.AgentConfigInfo{
+ Toolsets: []runtime.ToolsetDetail{
+ {Name: "filesystem", State: runtime.ToolsetStarted, Tools: []string{"read_file", "write_file"}},
+ {Name: "git", State: runtime.ToolsetStopped, Tools: []string{"status", "commit"}},
+ {Name: "search", Kind: "MCP", State: runtime.ToolsetError},
+ },
+ })
+
+ assert.Contains(t, out, "Toolsets (3)")
+ // Started: green marker, Built-in kind, live tool names (no "declared:").
+ assert.Contains(t, out, "● filesystem (Built-in) · 2 tools")
+ assert.Contains(t, out, "read_file, write_file")
+ assert.NotContains(t, out, "declared: read_file")
+ // Stopped: hollow marker, declared allow-list.
+ assert.Contains(t, out, "○ git (Built-in) · 2 tools")
+ assert.Contains(t, out, "declared: status, commit")
+ // Error: warning marker, Kind label, no tools sub-line.
+ assert.Contains(t, out, "⚠ search (MCP)")
+}
+
+// TestAgentDetailsDialog_RendersLiveStateLimitsOptionsSkills verifies the live
+// "current agent" line, the limits line (only set values), the options line
+// (only enabled flags), and the compact skills list.
+func TestAgentDetailsDialog_RendersLiveStateLimitsOptionsSkills(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{Name: "root", Model: "gpt-5.4"}, runtime.AgentConfigInfo{
+ IsCurrent: true,
+ MaxIterations: 50,
+ NumHistoryItems: 40,
+ MaxConsecutiveToolCalls: 5,
+ Options: []string{"add-date", "redact-secrets"},
+ Skills: []string{"debugging", "refactor"},
+ })
+
+ assert.Contains(t, out, "● current agent")
+ assert.Contains(t, out, "Limits: max-iter 50 · history 40 · max-tool-calls 5")
+ assert.Contains(t, out, "Options: add-date · redact-secrets")
+ assert.Contains(t, out, "Skills (2): debugging, refactor")
+}
+
+// TestAgentDetailsDialog_LimitsOmitsUnsetValues verifies the limits line only
+// lists the limits that are actually set (non-zero).
+func TestAgentDetailsDialog_LimitsOmitsUnsetValues(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{Name: "root", Model: "gpt"}, runtime.AgentConfigInfo{
+ NumHistoryItems: 40,
+ })
+ assert.Contains(t, out, "Limits: history 40")
+ assert.NotContains(t, out, "max-iter", "unset max-iter is omitted")
+ assert.NotContains(t, out, "max-tool-calls", "unset max-tool-calls is omitted")
+}
+
+// TestAgentDetailsDialog_OmitsInstruction documents that the inspector never
+// renders the agent's instruction/system prompt: neither AgentDetails nor
+// AgentConfigInfo carries it, and no section heading exposes it.
+func TestAgentDetailsDialog_OmitsInstruction(t *testing.T) {
+ t.Parallel()
+
+ out := renderAgentDetails(runtime.AgentDetails{
+ Name: "root",
+ Description: "Routes work to sub-agents",
+ Model: "gpt-5.4",
+ }, runtime.AgentConfigInfo{
+ SubAgents: []string{"coder"},
+ })
+
+ assert.Contains(t, out, "Routes work to sub-agents", "description is shown")
+ assert.NotContains(t, out, "Instruction", "the system prompt is never surfaced")
+ assert.NotContains(t, out, "System prompt")
+}
diff --git a/pkg/tui/dialog/cost.go b/pkg/tui/dialog/cost.go
index 5b54cb5d6..1109b146a 100644
--- a/pkg/tui/dialog/cost.go
+++ b/pkg/tui/dialog/cost.go
@@ -4,7 +4,6 @@ import (
"cmp"
"fmt"
"slices"
- "strconv"
"strings"
"time"
@@ -17,6 +16,7 @@ import (
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/tui/components/notification"
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
+ "github.com/docker/docker-agent/pkg/tui/components/toolcommon"
"github.com/docker/docker-agent/pkg/tui/core"
"github.com/docker/docker-agent/pkg/tui/core/layout"
"github.com/docker/docker-agent/pkg/tui/styles"
@@ -529,14 +529,7 @@ func formatCostPadded(cost float64) string {
}
func formatTokenCount(count int64) string {
- switch {
- case count >= 1_000_000:
- return fmt.Sprintf("%.1fM", float64(count)/1_000_000)
- case count >= 1_000:
- return fmt.Sprintf("%.1fK", float64(count)/1_000)
- default:
- return strconv.FormatInt(count, 10)
- }
+ return toolcommon.FormatTokenCount(count)
}
func formatDuration(d time.Duration) string {
diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go
index ca06aec70..4d2ed561a 100644
--- a/pkg/tui/handlers.go
+++ b/pkg/tui/handlers.go
@@ -328,6 +328,20 @@ func (m *appModel) handleSwitchAgent(agentName string) (tea.Model, tea.Cmd) {
)
}
+// handleShowAgentDetails opens the read-only agent-details dialog for the named
+// agent, looking it up in the available-agents roster.
+func (m *appModel) handleShowAgentDetails(agentName string) (tea.Model, tea.Cmd) {
+ for _, agent := range m.sessionState.AvailableAgents() {
+ if agent.Name == agentName {
+ cfg := m.application.AgentConfigInfo(agentName)
+ return m, core.CmdHandler(dialog.OpenDialogMsg{
+ Model: dialog.NewAgentDetailsDialog(agent, cfg),
+ })
+ }
+ }
+ return m, nil
+}
+
func (m *appModel) handleCycleAgent() (tea.Model, tea.Cmd) {
availableAgents := m.sessionState.AvailableAgents()
if len(availableAgents) <= 1 {
@@ -520,13 +534,14 @@ func (m *appModel) handleCycleThinkingLevel() (tea.Model, tea.Cmd) {
if !m.application.SupportsModelSwitching() {
return m, notification.InfoCmd("Thinking levels can't be changed with remote runtimes")
}
- if _, err := m.application.CycleAgentThinkingLevel(context.Background()); err != nil {
+ level, err := m.application.CycleAgentThinkingLevel(context.Background())
+ if err != nil {
if errors.Is(err, runtime.ErrUnsupported) {
return m, notification.InfoCmd("Current model does not support thinking levels")
}
return m, notification.ErrorCmd(fmt.Sprintf("Failed to change thinking level: %v", err))
}
- return m, nil
+ return m, notification.InfoCmd(styles.ThinkingGlyph + " Thinking: " + level.String())
}
func (m *appModel) handleChangeModel(modelRef string) (tea.Model, tea.Cmd) {
diff --git a/pkg/tui/messages/agent.go b/pkg/tui/messages/agent.go
index 49bed885a..18a5b6cd4 100644
--- a/pkg/tui/messages/agent.go
+++ b/pkg/tui/messages/agent.go
@@ -5,6 +5,10 @@ type (
// SwitchAgentMsg switches to a different agent.
SwitchAgentMsg struct{ AgentName string }
+ // ShowAgentDetailsMsg opens the read-only agent-details dialog for the
+ // named agent (clicking the current agent's card or Ctrl+clicking any agent).
+ ShowAgentDetailsMsg struct{ AgentName string }
+
// AgentCommandMsg sends a command to the agent.
AgentCommandMsg struct{ Command string }
diff --git a/pkg/tui/page/chat/agent_click_test.go b/pkg/tui/page/chat/agent_click_test.go
new file mode 100644
index 000000000..d9ac0c34c
--- /dev/null
+++ b/pkg/tui/page/chat/agent_click_test.go
@@ -0,0 +1,70 @@
+package chat
+
+import (
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ msgtypes "github.com/docker/docker-agent/pkg/tui/messages"
+)
+
+func TestAgentClickCmd_LeftClickSwitches(t *testing.T) {
+ t.Parallel()
+
+ cmd := (&chatPage{}).agentClickCmd("helper", tea.MouseLeft, 0)
+ require.NotNil(t, cmd)
+
+ msg, ok := cmd().(msgtypes.SwitchAgentMsg)
+ require.True(t, ok, "a plain left-click switches to the agent")
+ assert.Equal(t, "helper", msg.AgentName)
+}
+
+// TestAgentClickCmd_LeftClickOnCurrentAgentStillSwitches documents the routing
+// change: left-click now always switches, even on the already-current agent (a
+// harmless no-op switch). Details are reached via right-click / Ctrl+click.
+func TestAgentClickCmd_LeftClickOnCurrentAgentStillSwitches(t *testing.T) {
+ t.Parallel()
+
+ cmd := (&chatPage{}).agentClickCmd("root", tea.MouseLeft, 0)
+ require.NotNil(t, cmd)
+
+ _, ok := cmd().(msgtypes.SwitchAgentMsg)
+ assert.True(t, ok, "left-click always switches, never opens details")
+}
+
+func TestAgentClickCmd_RightClickOpensDetails(t *testing.T) {
+ t.Parallel()
+
+ cmd := (&chatPage{}).agentClickCmd("helper", tea.MouseRight, 0)
+ require.NotNil(t, cmd)
+
+ msg, ok := cmd().(msgtypes.ShowAgentDetailsMsg)
+ require.True(t, ok, "right-click opens the details dialog")
+ assert.Equal(t, "helper", msg.AgentName)
+}
+
+func TestAgentClickCmd_CtrlLeftClickOpensDetails(t *testing.T) {
+ t.Parallel()
+
+ cmd := (&chatPage{}).agentClickCmd("helper", tea.MouseLeft, tea.ModCtrl)
+ require.NotNil(t, cmd)
+
+ msg, ok := cmd().(msgtypes.ShowAgentDetailsMsg)
+ require.True(t, ok, "Ctrl+left-click opens the details dialog (fallback)")
+ assert.Equal(t, "helper", msg.AgentName)
+}
+
+func TestAgentClickCmd_EmptyAgentNoCmd(t *testing.T) {
+ t.Parallel()
+
+ assert.Nil(t, (&chatPage{}).agentClickCmd("", tea.MouseLeft, 0))
+}
+
+func TestAgentClickCmd_OtherButtonNoCmd(t *testing.T) {
+ t.Parallel()
+
+ assert.Nil(t, (&chatPage{}).agentClickCmd("helper", tea.MouseMiddle, 0),
+ "middle-click is not a handled gesture")
+}
diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go
index 66a87b0c6..37805f275 100644
--- a/pkg/tui/page/chat/input_handlers.go
+++ b/pkg/tui/page/chat/input_handlers.go
@@ -149,11 +149,8 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
}
case TargetSidebarAgent:
- if msg.Button == tea.MouseLeft {
- if hit.AgentName != "" {
- return p, core.CmdHandler(msgtypes.SwitchAgentMsg{AgentName: hit.AgentName})
- }
- return p, nil
+ if cmd := p.agentClickCmd(hit.AgentName, msg.Button, msg.Mod); cmd != nil {
+ return p, cmd
}
case TargetMessages:
@@ -173,6 +170,25 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
return p, cmd
}
+// agentClickCmd resolves a sidebar agent click to its command: a right-click or
+// Ctrl+left-click on any agent opens the read-only details dialog; a plain
+// left-click switches to it (switching to the already-current agent is a
+// harmless no-op). Returns nil when no agent was resolved or the gesture isn't
+// one we handle.
+func (p *chatPage) agentClickCmd(agentName string, button tea.MouseButton, mod tea.KeyMod) tea.Cmd {
+ if agentName == "" {
+ return nil
+ }
+ switch {
+ case button == tea.MouseRight, button == tea.MouseLeft && mod == tea.ModCtrl:
+ return core.CmdHandler(msgtypes.ShowAgentDetailsMsg{AgentName: agentName})
+ case button == tea.MouseLeft:
+ return core.CmdHandler(msgtypes.SwitchAgentMsg{AgentName: agentName})
+ default:
+ return nil
+ }
+}
+
// handleMouseMotion handles mouse motion events.
func (p *chatPage) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd) {
if p.isDraggingSidebar {
diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go
index 73993e7cc..180c08c6b 100644
--- a/pkg/tui/styles/styles.go
+++ b/pkg/tui/styles/styles.go
@@ -106,6 +106,21 @@ const (
// DoubleClickThreshold is the maximum time between clicks to register as a double-click
DoubleClickThreshold = 400 * time.Millisecond
+
+ // ThinkingGlyph marks thinking/reasoning state in the TUI (reasoning-block
+ // badge, sidebar thinking labels, Shift+Tab toast). U+273B is single-width
+ // with no emoji-presentation variant, so it is safe for column alignment.
+ ThinkingGlyph = "✻"
+
+ // TokenGlyph marks token-budget figures (Token Usage tab, token-based
+ // thinking badges). U+25C9 is single-width and non-emoji.
+ TokenGlyph = "◉"
+
+ // GaugeFilled and GaugeEmpty are the filled/empty cells of the compact
+ // effort gauge shown in sidebar roster rows. U+25B0/U+25B1 are single-width
+ // and non-emoji, so a fixed-width gauge keeps the badge column aligned.
+ GaugeFilled = "▰"
+ GaugeEmpty = "▱"
)
var (
diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go
index 593ea03a2..c8bcadc3c 100644
--- a/pkg/tui/tui.go
+++ b/pkg/tui/tui.go
@@ -870,6 +870,9 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case messages.SwitchAgentMsg:
return m.handleSwitchAgent(msg.AgentName)
+ case messages.ShowAgentDetailsMsg:
+ return m.handleShowAgentDetails(msg.AgentName)
+
// --- Session browser ---
case messages.OpenSessionBrowserMsg: