Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,52 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k

Slash commands (both built-in and named) execute immediately when entered. Regular chat messages are queued and processed in order. This means you can invoke a slash command to interrupt or direct the agent even while it is mid-response.

### Agents Panel

The sidebar's **Agents** section lists every agent in the team. The current agent is shown as a focus **card** (rendered in place at its position in the list) with its name, a wrapped description, its full `provider/model`, and a thinking line. Every other agent is shown as a compact **two-line row** — line 1 is the shortcut/spinner, the agent name (in its accent color), and a right-aligned thinking **gauge**; line 2 is the indented full `provider/model` — so a large team stays scannable while still showing each model. Agents are separated by a blank line so the two-line rows stay visually distinct. The effort **gauge** is the only visual language for thinking; the focus card and the Agent Inspector spell out the exact level alongside it. Left-click any agent to switch to it.

#### Agent inspector

Open a read-only **Agent Inspector** to inspect any agent's full configuration combined with its live state. The instruction/system prompt is deliberately omitted; everything else the agent declares is shown:

- **Right-click any agent** (card or row) to open the inspector without switching to it.
- **<kbd>Ctrl</kbd>+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 <kbd>Esc</kbd> 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 `◉ <count>`. 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: <level>` 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:

Expand Down Expand Up @@ -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: <level>` 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 |
Expand Down
10 changes: 10 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
164 changes: 164 additions & 0 deletions pkg/runtime/agent_inspector_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
27 changes: 27 additions & 0 deletions pkg/runtime/agent_tools_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
5 changes: 3 additions & 2 deletions pkg/runtime/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/runtime/model_switcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}, ""},
}

Expand Down
Loading
Loading