From fcb3932cb2c46e8c5f8dec987d746634d4d160db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Sat, 13 Jun 2026 22:51:00 +0200 Subject: [PATCH 1/3] feat(tui): clearer thinking-state vocabulary (effort gauge, labels, helpers) Replace the ambiguous bare on/off thinking label with a self-describing visual language: a 6-cell lossless effort gauge, an adaptive->auto and token-count label split, and left-truncation that preserves model-name tails. Adds shared toolcommon helpers (effort_gauge, thinking, tokencount, truncate) and de-duplicates formatTokenCount. Groundwork for the agents-panel redesign and the agent inspector. --- pkg/runtime/event.go | 5 +- pkg/runtime/model_switcher_test.go | 4 +- pkg/tui/components/toolcommon/effort_gauge.go | 135 ++++++++++++++++++ .../toolcommon/effort_gauge_test.go | 110 ++++++++++++++ pkg/tui/components/toolcommon/thinking.go | 40 ++++++ .../components/toolcommon/thinking_test.go | 27 ++++ pkg/tui/components/toolcommon/tokencount.go | 21 +++ pkg/tui/components/toolcommon/truncate.go | 35 +++++ .../components/toolcommon/truncate_test.go | 74 ++++++++++ pkg/tui/dialog/cost.go | 11 +- pkg/tui/styles/styles.go | 15 ++ 11 files changed, 464 insertions(+), 13 deletions(-) create mode 100644 pkg/tui/components/toolcommon/effort_gauge.go create mode 100644 pkg/tui/components/toolcommon/effort_gauge_test.go create mode 100644 pkg/tui/components/toolcommon/thinking.go create mode 100644 pkg/tui/components/toolcommon/thinking_test.go create mode 100644 pkg/tui/components/toolcommon/tokencount.go create mode 100644 pkg/tui/components/toolcommon/truncate_test.go 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/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/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/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 ( From 4574fcd761972a4b5e35a828653a909a08faac29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Sat, 13 Jun 2026 22:51:21 +0200 Subject: [PATCH 2/3] feat(tui): redesign the sidebar Agents panel (focus card + compact roster) Render the current agent as a focus card in place and the other agents as two-line compact rows (name + effort gauge, then the full model id), separated by blank lines, with a width-aware degradation ladder and left-truncated model tails. Harness-backed agents show their harness type instead of a blank model. Reworks the click-zone mapping to explicit per-line ownership so clicks keep resolving to the right agent. --- pkg/agent/agent.go | 10 + .../components/sidebar/agent_click_test.go | 102 +++++ .../components/sidebar/agent_panel_test.go | 424 ++++++++++++++++++ .../components/sidebar/effort_gauge_test.go | 128 ++++++ pkg/tui/components/sidebar/layout.go | 13 + pkg/tui/components/sidebar/sidebar.go | 388 ++++++++++++---- .../components/sidebar/token_usage_test.go | 16 + 7 files changed, 998 insertions(+), 83 deletions(-) create mode 100644 pkg/tui/components/sidebar/agent_panel_test.go create mode 100644 pkg/tui/components/sidebar/effort_gauge_test.go 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/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") +} From eba9a9402ae1b02f7396ec363af44ab7c5b6ac8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Sat, 13 Jun 2026 22:51:40 +0200 Subject: [PATCH 3/3] feat(tui): add right-click Agent Inspector with live + configured details Add a read-only agent-details modal opened by right-click (or Ctrl+click); plain left-click switches agent. The inspector shows the agent's configuration (model, fallback, thinking, sub-agents, handoffs, skills, limits, options, toolsets with their declared tools, commands) and live state (current-agent marker and per-toolset started/stopped/error status with live tool names when started). Retains the raw per-agent config on the team and adds per-agent toolset-status and config accessors to source it. --- docs/features/tui/index.md | 47 ++++- pkg/app/app.go | 20 ++ pkg/runtime/agent_inspector_test.go | 164 +++++++++++++++ pkg/runtime/agent_tools_test.go | 27 +++ pkg/runtime/runtime.go | 276 +++++++++++++++++++++++++- pkg/team/team.go | 30 +++ pkg/team/team_test.go | 40 ++++ pkg/teamloader/teamloader.go | 8 + pkg/teamloader/teamloader_test.go | 31 +++ pkg/tui/dialog/agent_details.go | 258 ++++++++++++++++++++++++ pkg/tui/dialog/agent_details_test.go | 236 ++++++++++++++++++++++ pkg/tui/handlers.go | 19 +- pkg/tui/messages/agent.go | 4 + pkg/tui/page/chat/agent_click_test.go | 70 +++++++ pkg/tui/page/chat/input_handlers.go | 26 ++- pkg/tui/tui.go | 3 + 16 files changed, 1247 insertions(+), 12 deletions(-) create mode 100644 pkg/runtime/agent_inspector_test.go create mode 100644 pkg/runtime/agent_tools_test.go create mode 100644 pkg/tui/dialog/agent_details.go create mode 100644 pkg/tui/dialog/agent_details_test.go create mode 100644 pkg/tui/page/chat/agent_click_test.go 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/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/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/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/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/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: