From e79ea6e366cf11faea9f8487d376f9b877e327ec Mon Sep 17 00:00:00 2001
From: Sajal Garg <9094703+gargsajal9@users.noreply.github.com>
Date: Sat, 12 Sep 2026 20:24:04 -0700
Subject: [PATCH] feat(tui): add an opt-in shared plans sidebar
Show the five most recently updated shared plans behind a global layout
preference. Reuse the existing external editor with displayed revision
guards, share asynchronous refreshes across tabs, and keep compact layouts
linked to the plan browser.
Cover configuration persistence, rendering, navigation and late-result
cancellation, and document the default-off behavior and freshness limits.
Refs #4115
Signed-off-by: Sajal Garg <9094703+gargsajal9@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
docs/configuration/user-settings/index.md | 6 +-
docs/features/tui/index.md | 22 +-
docs/tools/plan/index.md | 21 +-
pkg/tui/components/sidebar/collapsed_view.go | 9 +-
pkg/tui/components/sidebar/plans.go | 102 ++++++++
pkg/tui/components/sidebar/plans_test.go | 188 ++++++++++++++
pkg/tui/components/sidebar/sidebar.go | 39 ++-
pkg/tui/dialog/settings.go | 10 +-
pkg/tui/dialog/settings_test.go | 107 +++++++-
pkg/tui/handlers.go | 9 +
pkg/tui/messages/layout_test.go | 12 +
pkg/tui/messages/plans.go | 14 ++
pkg/tui/messages/settings.go | 5 +-
pkg/tui/page/chat/chat.go | 5 +
pkg/tui/page/chat/hittest.go | 11 +
pkg/tui/page/chat/input_handlers.go | 12 +
pkg/tui/page/chat/plans_test.go | 89 +++++++
pkg/tui/plans.go | 68 ++++-
pkg/tui/plans_sidebar.go | 66 +++++
pkg/tui/plans_sidebar_test.go | 246 +++++++++++++++++++
pkg/tui/settings_persistence_test.go | 55 ++++-
pkg/tui/tui.go | 28 ++-
pkg/userconfig/userconfig.go | 6 +-
pkg/userconfig/userconfig_test.go | 29 +++
24 files changed, 1129 insertions(+), 30 deletions(-)
create mode 100644 pkg/tui/components/sidebar/plans.go
create mode 100644 pkg/tui/components/sidebar/plans_test.go
create mode 100644 pkg/tui/page/chat/plans_test.go
create mode 100644 pkg/tui/plans_sidebar.go
create mode 100644 pkg/tui/plans_sidebar_test.go
diff --git a/docs/configuration/user-settings/index.md b/docs/configuration/user-settings/index.md
index 7ca1418a36..b49543ee2e 100644
--- a/docs/configuration/user-settings/index.md
+++ b/docs/configuration/user-settings/index.md
@@ -63,7 +63,7 @@ You rarely need to hand-edit this file. Most fields are managed from the TUI's `
## Layout Settings
-`layout` customizes the TUI's sidebar. The zero value (an omitted `layout:` block, or any field left out) is the default: sidebar on the right, every section visible, normal spacing.
+`layout` customizes the TUI's sidebar. The zero value (an omitted `layout:` block, or any field left out) is the default: sidebar on the right, all sections except **Plans** visible, normal spacing.
| Field | Type | Default | Description |
| --- | --- | --- | --- |
@@ -74,6 +74,7 @@ You rarely need to hand-edit this file. Most fields are managed from the TUI's `
| `hide_agents` | boolean | `false` | Hide the Agents section. |
| `active_agents_only` | boolean | `false` | Show only agents active in the current session in the Agents section (and the top/bottom band), instead of the whole configured team. Ignored while the Agents section is hidden. |
| `hide_tools` | boolean | `false` | Hide the Tools section. |
+| `show_plans` | boolean | `false` | Show the [Plans sidebar section](../../features/tui/index.md#plans-sidebar) for shared plans. Full left/right sidebars list the five most recently updated plans; compact layouts show a count and browser shortcut. |
| `hide_todos` | boolean | `false` | Hide the Todos section. |
```yaml
@@ -82,8 +83,11 @@ settings:
sidebar_position: left
section_spacing: compact
hide_usage: true
+ show_plans: true
```
+Enable **Plans** under `/settings` → **Appearance** → **Sidebar sections**, or set `settings.layout.show_plans: true` as above. This is a global user preference, not an agent configuration field or a per-session plan. It displays the same shared plan store as `/plans` and `docker agent plans`, without classifying free-form statuses as active or completed. The section remains hidden in lean mode and with `--sidebar=false`.
+
## Complete Example
```yaml
diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
index fe8ff541e7..73f28c0527 100644
--- a/docs/features/tui/index.md
+++ b/docs/features/tui/index.md
@@ -106,6 +106,26 @@ Slash commands (both built-in and named) execute immediately when entered. Regul
Agent-defined commands (prompts, URL links, agent-switching shortcuts) are configured under `commands:` in the agent YAML — see [Custom Commands](../../configuration/commands/index.md) for the full reference, including how to hide commands with `--disable-commands`.
+### Plans Sidebar
+
+The optional **Plans** section is off by default. Enable it under `/settings` → **Appearance** → **Sidebar sections**, or in your global [user settings](../../configuration/user-settings/index.md):
+
+```yaml
+# ~/.config/cagent/config.yaml
+settings:
+ layout:
+ show_plans: true
+```
+
+Plans are shared documents from the same store used by `/plans`, the [plan tools](../../tools/plan/index.md), and `docker agent plans` — not plans attached to the current session. A full left/right sidebar shows up to **five** plans, ordered by last update (newest first, unknown timestamps last, with name as the tie-breaker). Status is shown as free-form text; there is no active/completed classification or status filter.
+
+- **Single left-click a plan row** to open its content directly in `$VISUAL`/`$EDITOR`, guarded by the displayed revision. A stale revision is rejected rather than overwriting newer content.
+- **All plans** opens the shared plan browser. `/plans` and the Ctrl+K command palette remain the keyboard routes.
+- Top/bottom layouts and narrow or collapsed sidebar bands show only a compact `Plans (N) - open /plans` count and browser shortcut, not individual plan rows. Lean mode and `--sidebar=false` never show the section.
+- Changes from plan events in the current process and local edits refresh the shared metadata. To pick up changes from another process, use the sidebar's **Refresh plans** action or press r in the plan browser or detail view. There is no automatic polling or file watcher.
+
+Editing changes only the plan document. It does not approve a plan, execute its steps, or authorize tool writes.
+
### Agents Panel
The sidebar's **Agents** section lists every agent in the team and has two display modes selectable via **Sidebar info mode** in `/settings`:
@@ -558,7 +578,7 @@ The **Appearance** tab selects the theme and customizes the layout. Layout chang
- **Sidebar position**: `Right` (default), `Left`, `Top`, or `Bottom`. Left/right keep the full vertical sidebar next to the chat; top/bottom render it as a compact horizontal band above or below the chat (session title, working directory, token usage, plus a one-line summary of the current agent and its model; in multi-agent configurations all team agents are listed by name after the current agent).
- **Sidebar info mode**: `Compact` (default) or `Detailed`. Controls how the Agents panel renders agent rows — see [Agents Panel](#agents-panel) for details. Persisted as `settings.layout.sidebar_info_mode: detailed`; compact is the default and omitted from the config.
- **Section spacing**: `Compact`, `Normal` (default), or `Relaxed`, the number of blank lines between the sidebar sections (1, 2, or 3).
-- **Sidebar sections**: toggle the visibility of the **Session path** (the working directory line, including its git branch) and the **Token usage**, **Agents**, **Tools**, and **Todos** sections. The session title is always shown.
+- **Sidebar sections**: toggle the visibility of the **Session path** (the working directory line, including its git branch) and the **Token usage**, **Agents**, **Tools**, **Plans**, and **Todos** sections. All are visible by default except [Plans](#plans-sidebar), which is opt-in. The session title is always shown.
Appearance also controls split-diff rendering, expanded thinking, whether tool results are hidden by default, and **Show startup banner** — the ASCII-art banner drawn on an empty conversation (persisted as `settings.show_banner: false` when turned off, and honored by the lean TUI too). Select **Theme** to open the theme picker.
diff --git a/docs/tools/plan/index.md b/docs/tools/plan/index.md
index 97a9d34e9d..53076c4c43 100644
--- a/docs/tools/plan/index.md
+++ b/docs/tools/plan/index.md
@@ -142,7 +142,7 @@ $ docker agent plans update release --file ./plan.md --expected-version 1
### The `/plans` browser in the TUI
-Inside the full-screen TUI, the `/plans` slash command (also in the Ctrl+K command palette) opens a plan browser over the same store the agents use, so changes made by agents mid-session appear immediately. The list shows every shared plan with its scope, name, status, version, last update time, and title.
+Inside the full-screen TUI, the `/plans` slash command (also in the Ctrl+K command palette) opens a plan browser over the same store the agents use, so changes made by agents in the same process appear immediately. The list shows every shared plan with its scope, name, status, version, last update time, and title.
Keybindings:
@@ -150,7 +150,7 @@ Keybindings:
| --- | ------ |
| ↑/↓, mouse | Navigate; Enter or double-click opens a detail view with the full metadata and scrollable markdown content |
| / | Filter by name, title, status, or scope (Esc leaves filter mode) |
-| r | Refresh from storage |
+| r | Refresh from storage in the browser or detail view, including changes from other processes |
| x | Export the selected plan to `.md` in the session's working directory. An existing file is never overwritten — the export fails with a notification instead |
| s | Set a plan's free-form status via a small input dialog |
| e | Edit a plan's content in `$VISUAL`/`$EDITOR` |
@@ -158,7 +158,22 @@ Keybindings:
| d | Delete a plan after a confirmation that names the plan and its version |
| Esc | Close the detail view / the browser |
-Every mutation is guarded by the version shown on screen (the same optimistic locking as `last_known_revision`): if an agent changed the plan in the meantime, the write is rejected, a notification reports the current version, the newer content is left intact and re-read into the browser, and an edit draft is kept in a temp file so nothing is lost. The browser also refreshes live when agents in the same process write, re-status, or delete plans; in the lean TUI, which has no overlays, `/plans` is unavailable.
+Every mutation is guarded by the version shown on screen (the same optimistic locking as `last_known_revision`): if an agent changed the plan in the meantime, the write is rejected, a notification reports the current version, the newer content is left intact and re-read into the browser, and an edit draft is kept in a temp file so nothing is lost. The browser also refreshes live when agents in the same process write, re-status, or delete plans. Changes from other processes require an explicit refresh; there is no automatic polling or file watcher. In the lean TUI, which has no overlays, `/plans` is unavailable.
+
+### Optional Plans sidebar
+
+Enable **Plans** under `/settings` → **Appearance** → **Sidebar sections** to keep shared plans visible alongside the chat. It is off by default and saved as a global user preference, separate from agent YAML:
+
+```yaml
+# ~/.config/cagent/config.yaml
+settings:
+ layout:
+ show_plans: true
+```
+
+The full left/right sidebar lists up to five shared plans by last update, newest first (unknown timestamps last, ties by name), plus **All plans** to open the browser. The free-form status is displayed without an active/completed classification. Single left-click a plan row to open it directly in `$VISUAL`/`$EDITOR` at the displayed revision; stale revisions are rejected and newer content is preserved. Editing changes only the document — it never approves the plan, executes its steps, or authorizes tool writes.
+
+Top/bottom layouts and narrow or collapsed sidebar bands show a compact `Plans (N) - open /plans` count and browser shortcut instead of individual rows. Plan events in the current process and local edits refresh the shared metadata; use the sidebar's **Refresh plans** action or r in the browser/detail view for cross-process changes. The section is hidden in lean mode and with `--sidebar=false`; `/plans` and the command palette remain available in the full TUI even when the section is off. See [Plans Sidebar](../../features/tui/index.md#plans-sidebar) for details.
> [!TIP]
> **Plan vs. Todo vs. Tasks**
diff --git a/pkg/tui/components/sidebar/collapsed_view.go b/pkg/tui/components/sidebar/collapsed_view.go
index 7501e919c3..c4ecd2dc9e 100644
--- a/pkg/tui/components/sidebar/collapsed_view.go
+++ b/pkg/tui/components/sidebar/collapsed_view.go
@@ -17,7 +17,8 @@ type CollapsedViewModel struct {
UsageSummary string
// InfoLine is the compact agents/tools/todos summary shown when the
// sidebar renders as a horizontal band.
- InfoLine string
+ InfoLine string
+ PlansSummary string
// Layout decisions computed from the data
TitleAndIndicatorOnOneLine bool
@@ -48,6 +49,9 @@ func (vm CollapsedViewModel) LineCount() int {
if vm.InfoLine != "" {
lines += linesNeeded(lipgloss.Width(vm.InfoLine), vm.ContentWidth)
}
+ if vm.PlansSummary != "" {
+ lines += linesNeeded(lipgloss.Width(vm.PlansSummary), vm.ContentWidth)
+ }
return lines
}
@@ -109,6 +113,9 @@ func RenderCollapsedView(vm CollapsedViewModel) string {
if vm.InfoLine != "" {
lines = append(lines, vm.InfoLine)
}
+ if vm.PlansSummary != "" {
+ lines = append(lines, vm.PlansSummary)
+ }
return strings.Join(lines, "\n")
}
diff --git a/pkg/tui/components/sidebar/plans.go b/pkg/tui/components/sidebar/plans.go
new file mode 100644
index 0000000000..7f260f3587
--- /dev/null
+++ b/pkg/tui/components/sidebar/plans.go
@@ -0,0 +1,102 @@
+package sidebar
+
+import (
+ "cmp"
+ "fmt"
+ "slices"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/docker/docker-agent/pkg/plans"
+ "github.com/docker/docker-agent/pkg/tui/components/notification"
+ "github.com/docker/docker-agent/pkg/tui/components/toolcommon"
+ "github.com/docker/docker-agent/pkg/tui/core"
+ "github.com/docker/docker-agent/pkg/tui/messages"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+const recentPlanLimit = 5
+
+type planClickZone struct {
+ kind ClickResult
+ name string
+}
+
+func (m *model) setPlans(data messages.PlanSidebarDataMsg) {
+ m.planData = data
+ m.recentPlans = slices.Clone(data.Result.Plans)
+ slices.SortFunc(m.recentPlans, func(a, b plans.Plan) int {
+ if order := b.UpdatedAt.Compare(a.UpdatedAt); order != 0 {
+ return order
+ }
+ return cmp.Compare(a.Name, b.Name)
+ })
+ m.recentPlans = m.recentPlans[:min(len(m.recentPlans), recentPlanLimit)]
+ m.invalidateCache()
+}
+
+func (m *model) EditPlan(name, tabID string) tea.Cmd {
+ for _, p := range m.recentPlans {
+ if p.Name == name && p.Version != nil {
+ return core.CmdHandler(messages.EditSidebarPlanMsg{
+ TabID: tabID, Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version,
+ })
+ }
+ }
+ return notification.WarningCmd("Plan is no longer available for editing. Refresh plans and retry.")
+}
+
+func (m *model) plansSection(width int) (string, []planClickZone) {
+ var lines []string
+ var zones []planClickZone
+ add := func(text string, zone planClickZone) {
+ text = strings.Join(strings.Fields(text), " ")
+ lines = append(lines, toolcommon.TruncateText(text, max(1, width)))
+ zones = append(zones, zone)
+ }
+ switch {
+ case m.planData.Err != nil:
+ label := "Plans unavailable; refresh to retry"
+ if len(m.recentPlans) > 0 {
+ label = "Plans unavailable; showing stale data"
+ }
+ add(styles.MutedStyle.Render(label), planClickZone{})
+ case m.planData.Loading:
+ add(styles.MutedStyle.Render("Loading plans"), planClickZone{})
+ case len(m.recentPlans) == 0:
+ add(styles.MutedStyle.Render("No plans"), planClickZone{})
+ }
+ if len(m.planData.Result.Warnings) > 0 {
+ add(styles.MutedStyle.Render(fmt.Sprintf("%d plan(s) could not be read", len(m.planData.Result.Warnings))), planClickZone{})
+ }
+ for _, p := range m.recentPlans {
+ zone := planClickZone{kind: ClickPlan, name: p.Name}
+ add(styles.BaseStyle.Render(p.Name), zone)
+ if p.Title != "" {
+ add(styles.MutedStyle.Render(p.Title), zone)
+ }
+ if p.Status != "" {
+ add(styles.MutedStyle.Render(p.Status), zone)
+ }
+ }
+ add(styles.MutedStyle.Render(fmt.Sprintf("All plans (%d)", len(m.planData.Result.Plans))), planClickZone{kind: ClickPlanBrowser})
+ add(styles.MutedStyle.Render("Refresh plans"), planClickZone{kind: ClickPlanRefresh})
+ return m.renderTab("Plans", strings.Join(lines, "\n"), width), zones
+}
+
+func (m *model) plansSummary() string {
+ if !m.sectionVisibility.ShowPlans {
+ return ""
+ }
+ label := fmt.Sprintf("Plans (%d) - open /plans", len(m.planData.Result.Plans))
+ switch {
+ case m.planData.Err != nil:
+ label = "Plans unavailable - open /plans"
+ case m.planData.Loading:
+ label = "Loading plans - open /plans"
+ case len(m.planData.Result.Warnings) > 0:
+ label += " (warnings)"
+ }
+ return styles.MutedStyle.Render(label)
+}
diff --git a/pkg/tui/components/sidebar/plans_test.go b/pkg/tui/components/sidebar/plans_test.go
new file mode 100644
index 0000000000..53fb891a01
--- /dev/null
+++ b/pkg/tui/components/sidebar/plans_test.go
@@ -0,0 +1,188 @@
+package sidebar
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/plans"
+ "github.com/docker/docker-agent/pkg/tui/animation"
+ "github.com/docker/docker-agent/pkg/tui/messages"
+ "github.com/docker/docker-agent/pkg/tui/service"
+)
+
+func newPlanSidebar(t *testing.T) *model {
+ t.Helper()
+ m := New(animation.NewRuntime(), t.Context(), &service.SessionState{}).(*model)
+ m.sessionTitle = "T"
+ m.workingDirectory = ""
+ m.SetSize(40, 80)
+ m.SetSectionVisibility(SectionVisibility{
+ ShowPlans: true, HideUsage: true, HideAgents: true, HideTools: true, HideTodos: true,
+ })
+ return m
+}
+
+func TestPlanSidebar_RecentOrderAndLimit(t *testing.T) {
+ t.Parallel()
+ for _, count := range []int{0, 1, 5, 8} {
+ t.Run(strconv.Itoa(count), func(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ var input []plans.Plan
+ for i := range count {
+ input = append(input, plans.Plan{
+ Name: fmt.Sprintf("plan-%d", i), UpdatedAt: time.Unix(int64(i+1), 0), Version: new(i + 1),
+ })
+ }
+ m.setPlans(messages.PlanSidebarDataMsg{Result: plans.ListResult{Plans: input}})
+ require.Len(t, m.recentPlans, min(count, recentPlanLimit))
+ for i, p := range m.recentPlans {
+ assert.Equal(t, fmt.Sprintf("plan-%d", count-i-1), p.Name)
+ }
+ for i, p := range input {
+ assert.Equal(t, fmt.Sprintf("plan-%d", i), p.Name, "do not reorder the service/browser snapshot")
+ }
+ view := ansi.Strip(m.View())
+ assert.Contains(t, view, fmt.Sprintf("All plans (%d)", count))
+ if count == 0 {
+ assert.Contains(t, view, "No plans")
+ }
+ })
+ }
+}
+
+func TestPlanSidebar_TiesAndUnknownDates(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ stamp := time.Unix(100, 0)
+ m.setPlans(messages.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{
+ {Name: "unknown"}, {Name: "z", UpdatedAt: stamp}, {Name: "a", UpdatedAt: stamp},
+ }}})
+ require.Len(t, m.recentPlans, 3)
+ assert.Equal(t, "a", m.recentPlans[0].Name)
+ assert.Equal(t, "z", m.recentPlans[1].Name)
+ assert.Equal(t, "unknown", m.recentPlans[2].Name)
+}
+
+func TestPlanSidebar_ClicksTrackCanonicalPlanAndVersion(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ m.setPlans(messages.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{
+ {Name: "first", Title: "Same title", Status: "awaiting-special-review", Version: new(4)},
+ {Name: "second", Title: "Same title", Version: new(8)},
+ }}})
+ view := ansi.Strip(m.View())
+ assert.Contains(t, view, "awaiting-special-review")
+ found := map[ClickResult]int{}
+ for y, line := range strings.Split(view, "\n") {
+ kind, name := m.HandleClickType(m.layoutCfg.PaddingLeft+1, y)
+ if kind == ClickNone {
+ continue
+ }
+ found[kind]++
+ if kind == ClickPlan {
+ msg, ok := m.EditPlan(name, "tab")().(messages.EditSidebarPlanMsg)
+ require.True(t, ok)
+ assert.Equal(t, plans.SharedRef(name), msg.Ref)
+ assert.Equal(t, "tab", msg.TabID)
+ if name == "first" {
+ assert.Equal(t, 4, msg.ExpectedVersion)
+ } else {
+ assert.Equal(t, "second", name)
+ assert.Equal(t, 8, msg.ExpectedVersion)
+ }
+ }
+ assert.NotEmpty(t, strings.TrimSpace(line))
+ }
+ assert.Equal(t, 5, found[ClickPlan], "all name/title/status lines belong to their plan")
+ assert.Equal(t, 1, found[ClickPlanBrowser])
+ assert.Equal(t, 1, found[ClickPlanRefresh])
+ before := m.VisualGeneration()
+ m.SetSectionVisibility(SectionVisibility{})
+ assert.Greater(t, m.VisualGeneration(), before)
+ for y := range strings.Split(view, "\n") {
+ kind, _ := m.HandleClickType(m.layoutCfg.PaddingLeft+1, y)
+ assert.NotEqual(t, ClickPlan, kind, "hiding must immediately disable old click zones")
+ }
+ assert.NotContains(t, ansi.Strip(m.View()), "All plans")
+}
+
+func TestPlanSidebar_StateAndCompactGeometry(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ name string
+ data messages.PlanSidebarDataMsg
+ text string
+ }{
+ {"loading", messages.PlanSidebarDataMsg{Loading: true}, "Loading plans"},
+ {"empty", messages.PlanSidebarDataMsg{}, "No plans"},
+ {"error", messages.PlanSidebarDataMsg{Err: errors.New("unavailable")}, "Plans unavailable"},
+ {"warning", messages.PlanSidebarDataMsg{Result: plans.ListResult{Warnings: []string{"corrupt"}}}, "could not be read"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ m.setPlans(tc.data)
+ assert.Contains(t, ansi.Strip(m.View()), tc.text)
+ m.SetMode(ModeCollapsed)
+ for _, width := range []int{10, 40, 100} {
+ m.SetSize(width, 80)
+ view := ansi.Strip(m.View())
+ lines := strings.Split(view, "\n")
+ assert.Equal(t, len(lines)+1, m.CollapsedHeight(width))
+ kind, _ := m.HandleClickType(m.layoutCfg.PaddingLeft, len(lines)-1)
+ assert.Equal(t, ClickPlanBrowser, kind)
+ }
+ })
+ }
+}
+
+func TestPlanSidebar_ScrollbarAndScrolledRows(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ m.SetSize(30, 6)
+ m.setPlans(messages.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{
+ {Name: "one", Version: new(1)}, {Name: "two", Version: new(2)},
+ }}})
+ m.View()
+ require.True(t, m.cachedNeedsScrollbar)
+ for contentY, zone := range m.planClickZones {
+ if zone.kind != ClickPlan {
+ continue
+ }
+
+ m.scrollview.SetScrollOffset(contentY)
+ viewportY := contentY - m.scrollview.ScrollOffset()
+ kind, name := m.HandleClickType(m.layoutCfg.PaddingLeft, viewportY)
+ assert.Equal(t, ClickPlan, kind)
+ assert.Equal(t, zone.name, name)
+ kind, _ = m.HandleClickType(m.layoutCfg.PaddingLeft+m.contentWidth(true), viewportY)
+ assert.Equal(t, ClickNone, kind, "scrollbar must not edit plans")
+ }
+}
+
+func TestPlanSidebar_MultilineMetadataKeepsClickGeometry(t *testing.T) {
+ t.Parallel()
+ m := newPlanSidebar(t)
+ m.setPlans(messages.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{
+ {Name: "release", Title: "first\nsecond\tthird", Status: "pending\r\nreview", Version: new(1)},
+ }}})
+ view := ansi.Strip(m.View())
+ assert.Contains(t, view, "first second third")
+ assert.Contains(t, view, "pending review")
+ for y, line := range strings.Split(view, "\n") {
+ if strings.Contains(line, "first second") || strings.Contains(line, "pending review") {
+ kind, name := m.HandleClickType(m.layoutCfg.PaddingLeft, y)
+ assert.Equal(t, ClickPlan, kind)
+ assert.Equal(t, "release", name)
+ }
+ }
+}
diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go
index 0392bc7241..5b57dbf0bf 100644
--- a/pkg/tui/components/sidebar/sidebar.go
+++ b/pkg/tui/components/sidebar/sidebar.go
@@ -20,6 +20,7 @@ import (
"github.com/docker/docker-agent/pkg/effort"
"github.com/docker/docker-agent/pkg/gitbranch"
pathx "github.com/docker/docker-agent/pkg/path"
+ "github.com/docker/docker-agent/pkg/plans"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/tools"
@@ -58,8 +59,9 @@ const (
)
// SectionVisibility controls which optional sidebar sections are rendered.
-// The zero value shows everything.
+// The zero value shows the original sections; plans are opt-in.
type SectionVisibility struct {
+ ShowPlans bool
HideSessionPath bool
HideUsage bool
HideAgents bool
@@ -158,6 +160,7 @@ type Model interface {
VisualGeneration() uint64
// WorkingDirectory returns the working directory path displayed in the sidebar.
WorkingDirectory() string
+ EditPlan(name, tabID string) tea.Cmd
}
type gitBranchChangedMsg string
@@ -414,6 +417,9 @@ type model struct {
// rendering so click zones can be registered explicitly rather than inferred
// from blank-line heuristics.
agentLineOwners []string
+ planData messages.PlanSidebarDataMsg
+ recentPlans []plans.Plan
+ planClickZones map[int]planClickZone
}
// New creates a new sidebar bound to the given session state.
@@ -851,6 +857,9 @@ type ClickResult int
const (
ClickNone ClickResult = iota
+ ClickPlan
+ ClickPlanBrowser
+ ClickPlanRefresh
ClickStar
ClickTitle // Click on the title area (use double-click to edit)
ClickWorkingDir // Click on the working directory line
@@ -868,7 +877,7 @@ func (m *model) HandleClick(x, y int) bool {
}
// HandleClickType returns what was clicked (see ClickResult).
-// For ClickAgent, the second return value is the agent name.
+// For ClickAgent or ClickPlan, the second return value is the canonical name.
func (m *model) HandleClickType(x, y int) (ClickResult, string) {
// Account for left padding
adjustedX := x - m.layoutCfg.PaddingLeft
@@ -906,6 +915,12 @@ func (m *model) HandleClickType(x, y int) (ClickResult, string) {
// In collapsed mode, working dir line follows the title section.
// A hidden session path renders no line and must not keep a hit target.
vm := m.computeCollapsedViewModel(m.contentWidth(false))
+ if vm.PlansSummary != "" && adjustedX < vm.ContentWidth {
+ start := vm.LineCount() - 1 - linesNeeded(lipgloss.Width(vm.PlansSummary), vm.ContentWidth)
+ if y >= start && y < vm.LineCount()-1 {
+ return ClickPlanBrowser, ""
+ }
+ }
wdStartY := vm.titleSectionLines()
wdLines := linesNeeded(lipgloss.Width(vm.WorkingDir), vm.ContentWidth)
@@ -978,6 +993,11 @@ func (m *model) HandleClickType(x, y int) (ClickResult, string) {
if agentName, ok := m.agentClickZones[contentY]; ok {
return ClickAgent, agentName
}
+ if m.sectionVisibility.ShowPlans {
+ if zone, ok := m.planClickZones[contentY]; ok {
+ return zone.kind, zone.name
+ }
+ }
return ClickNone, ""
}
@@ -1211,6 +1231,9 @@ func (m *model) workingDirLine() string {
// Update handles messages and updates the component state.
func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case messages.PlanSidebarDataMsg:
+ m.setPlans(msg)
+ return m, nil
case gitBranchChangedMsg:
m.gitBranchName = string(msg)
m.invalidateCache()
@@ -1533,6 +1556,7 @@ func (m *model) computeCollapsedViewModel(contentWidth int) CollapsedViewModel {
WorkingIndicator: m.workingIndicatorCollapsed(),
WorkingDir: m.workingDirLine(),
InfoLine: m.collapsedInfoLine(contentWidth),
+ PlansSummary: toolcommon.TruncateText(m.plansSummary(), contentWidth),
ContentWidth: contentWidth,
}
if !m.sectionVisibility.HideUsage {
@@ -1794,6 +1818,17 @@ func (m *model) renderSections(contentWidth int) []string {
m.todoComp.SetSize(contentWidth)
appendSection(m.todoComp.Render())
}
+ m.planClickZones = nil
+ if m.sectionVisibility.ShowPlans {
+ section, zones := m.plansSection(contentWidth)
+ start := appendSection(section)
+ m.planClickZones = make(map[int]planClickZone)
+ for i, zone := range zones {
+ if zone.kind != ClickNone {
+ m.planClickZones[start+tabHeaderLines+i] = zone
+ }
+ }
+ }
return lines
}
diff --git a/pkg/tui/dialog/settings.go b/pkg/tui/dialog/settings.go
index 656216d458..2d3011aef6 100644
--- a/pkg/tui/dialog/settings.go
+++ b/pkg/tui/dialog/settings.go
@@ -41,6 +41,7 @@ const (
rowAgents
rowActiveAgents
rowTools
+ rowPlans
rowTodos
rowSplitDiff
rowExpandThinking
@@ -260,6 +261,8 @@ func (d *settingsDialog) changeValue(delta int) tea.Cmd {
}
case rowTools:
d.current.Layout.HideTools = !d.current.Layout.HideTools
+ case rowPlans:
+ d.current.Layout.ShowPlans = !d.current.Layout.ShowPlans
case rowTodos:
d.current.Layout.HideTodos = !d.current.Layout.HideTodos
case rowSplitDiff:
@@ -400,6 +403,7 @@ func (d *settingsDialog) renderAppearanceTab(content *Content, inner int) {
AddContent(d.renderToggleRow(rowAgents, "Agents", !d.current.Layout.HideAgents)).
AddContent(d.renderNestedToggleRow(rowActiveAgents, "Active agents only", d.current.Layout.ActiveAgentsOnly, d.current.Layout.HideAgents)).
AddContent(d.renderToggleRow(rowTools, "Tools", !d.current.Layout.HideTools)).
+ AddContent(d.renderToggleRow(rowPlans, "Plans", d.current.Layout.ShowPlans)).
AddContent(d.renderToggleRow(rowTodos, "Todos", !d.current.Layout.HideTodos))
}
content.AddSpace().
@@ -516,6 +520,9 @@ func visibleSectionLabels(s messages.LayoutSettings) []string {
if !s.HideTodos {
labels = append(labels, "todos")
}
+ if s.ShowPlans {
+ labels = append(labels, "plans")
+ }
return labels
}
@@ -556,9 +563,8 @@ func renderSidePreview(s messages.LayoutSettings, width int, onLeft bool) string
inner := width - 2
sideW := max(9, inner/3)
chatW := inner - sideW - 1
- const contentRows = 5
-
labels := visibleSectionLabels(s)
+ contentRows := max(5, len(labels))
sectionStyle := styles.TabAccentStyle
var lines []string
diff --git a/pkg/tui/dialog/settings_test.go b/pkg/tui/dialog/settings_test.go
index e64f13ad38..30cc537f80 100644
--- a/pkg/tui/dialog/settings_test.go
+++ b/pkg/tui/dialog/settings_test.go
@@ -27,6 +27,7 @@ func TestSettingsDialogNormalizesValues(t *testing.T) {
assert.Equal(t, messages.SidebarRight, d.current.Layout.SidebarPosition)
assert.Equal(t, messages.InfoModeCompact, d.current.Layout.SidebarInfoMode,
"empty info mode normalizes to compact")
+ assert.False(t, d.current.Layout.ShowPlans, "Plans is off by default")
raw, ok := NewSettingsDialog(messages.Preferences{
SendMode: messages.SendMode("bogus"),
@@ -59,6 +60,12 @@ func TestSettingsDialogNavigation(t *testing.T) {
d.Update(down)
require.Equal(t, rowSessionPath, d.selected[tabAppearance])
+ d.selected[tabAppearance] = rowTools
+ d.Update(down)
+ require.Equal(t, rowPlans, d.selected[tabAppearance])
+ d.Update(down)
+ require.Equal(t, rowTodos, d.selected[tabAppearance])
+
for range 20 {
d.Update(down)
}
@@ -90,7 +97,10 @@ func TestSettingsDialogTabSwitching(t *testing.T) {
func TestSettingsDialogWithoutVisualsTab(t *testing.T) {
t.Parallel()
- d, ok := NewSettingsDialog(messages.Preferences{SendMode: messages.SendModeSteer}, false).(*settingsDialog)
+ d, ok := NewSettingsDialog(messages.Preferences{
+ SendMode: messages.SendModeSteer,
+ Layout: messages.LayoutSettings{ShowPlans: true},
+ }, false).(*settingsDialog)
require.True(t, ok)
d.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
@@ -101,12 +111,21 @@ func TestSettingsDialogWithoutVisualsTab(t *testing.T) {
assert.NotContains(t, view, "Sidebar position")
assert.NotContains(t, view, "Sidebar info mode")
assert.NotContains(t, view, "Active agents only")
+ assert.NotContains(t, view, "Plans")
assert.Contains(t, view, "Split diff view")
assert.False(t, d.selectable(tabAppearance, rowInfoMode),
"the info mode row is not selectable without a sidebar")
assert.False(t, d.selectable(tabAppearance, rowActiveAgents),
"the agent filter row is not selectable without a sidebar")
+ assert.False(t, d.selectable(tabAppearance, rowPlans),
+ "the Plans row is not selectable without a sidebar")
+
+ d.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+ assert.Equal(t, rowSplitDiff, d.selected[tabAppearance], "navigation skips every sidebar row")
+ d.Update(tea.KeyPressMsg{Code: tea.KeyUp})
+ assert.Equal(t, rowTheme, d.selected[tabAppearance], "reverse navigation skips every sidebar row")
+ assert.True(t, d.current.Layout.ShowPlans, "hidden sidebar preferences are preserved")
}
func TestSettingsDialogCyclesPositionAndPreviews(t *testing.T) {
@@ -231,6 +250,67 @@ func TestSettingsDialogTogglesSection(t *testing.T) {
assert.False(t, d.current.Layout.HideUsage, "space must toggle back")
}
+func TestSettingsDialogPlansPreviewAndApply(t *testing.T) {
+ t.Parallel()
+
+ for _, tt := range []struct {
+ name string
+ initial bool
+ }{
+ {name: "enable", initial: false},
+ {name: "disable", initial: true},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ d := newTestSettingsDialog(t, messages.LayoutSettings{ShowPlans: tt.initial})
+ d.selected[tabAppearance] = rowPlans
+
+ _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeySpace})
+ msgs := collectMsgs(cmd)
+ require.Len(t, msgs, 1)
+ preview, ok := msgs[0].(messages.PreviewLayoutMsg)
+ require.True(t, ok, "toggling Plans emits a live preview")
+ assert.Equal(t, !tt.initial, preview.Layout.ShowPlans)
+ assert.Equal(t, d.current.Layout, preview.Layout)
+
+ _, cmd = d.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ msgs = collectMsgs(cmd)
+ require.Len(t, msgs, 2)
+ _, ok = msgs[0].(CloseDialogMsg)
+ require.True(t, ok)
+ applied, ok := msgs[1].(messages.ApplySettingsMsg)
+ require.True(t, ok)
+ assert.Equal(t, !tt.initial, applied.Preferences.Layout.ShowPlans)
+ })
+ }
+}
+
+func TestSettingsDialogCancelRestoresPlans(t *testing.T) {
+ t.Parallel()
+
+ for _, tt := range []struct {
+ name string
+ initial bool
+ }{
+ {name: "originally hidden", initial: false},
+ {name: "originally visible", initial: true},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ d := newTestSettingsDialog(t, messages.LayoutSettings{ShowPlans: tt.initial})
+ d.selected[tabAppearance] = rowPlans
+ d.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ require.Equal(t, !tt.initial, d.current.Layout.ShowPlans)
+
+ _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
+ msgs := collectMsgs(cmd)
+ require.Len(t, msgs, 2)
+ cancel, ok := msgs[1].(messages.CancelLayoutPreviewMsg)
+ require.True(t, ok)
+ assert.Equal(t, d.original.Layout, cancel.Original)
+ assert.Equal(t, tt.initial, cancel.Original.ShowPlans)
+ })
+ }
+}
+
func TestSettingsDialogTogglesActiveAgentsOnly(t *testing.T) {
t.Parallel()
@@ -435,6 +515,7 @@ func TestSettingsDialogViewShowsVisualsRows(t *testing.T) {
assert.Contains(t, view, "Agents")
assert.Contains(t, view, "Active agents only")
assert.Contains(t, view, "Tools")
+ assert.Contains(t, view, "[ ] Plans", "Plans is available but off by default")
assert.Contains(t, view, "Todos")
}
@@ -535,6 +616,7 @@ func TestRenderLayoutPreviewReflectsSections(t *testing.T) {
assert.Contains(t, full, "session/path", "a visible session path shows in the session label")
assert.Contains(t, full, "usage")
assert.Contains(t, full, "todos")
+ assert.NotContains(t, full, "plans")
trimmed := ansi.Strip(renderLayoutPreview(messages.LayoutSettings{
HideSessionPath: true,
@@ -548,6 +630,29 @@ func TestRenderLayoutPreviewReflectsSections(t *testing.T) {
assert.Contains(t, trimmed, "agents")
}
+func TestRenderLayoutPreviewShowsPlans(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, []string{"session/path", "usage", "agents", "tools", "todos"},
+ visibleSectionLabels(messages.LayoutSettings{}))
+ assert.Equal(t, []string{"session/path", "usage", "agents", "tools", "todos", "plans"},
+ visibleSectionLabels(messages.LayoutSettings{ShowPlans: true}))
+
+ for _, position := range sidebarPositions {
+ t.Run(string(position), func(t *testing.T) {
+ settings := messages.LayoutSettings{SidebarPosition: position, ShowPlans: true}
+ if position == messages.SidebarTop || position == messages.SidebarBottom {
+ settings.HideUsage = true
+ settings.HideAgents = true
+ settings.HideTools = true
+ }
+ preview := ansi.Strip(renderLayoutPreview(settings, previewMaxWidth))
+ assert.Contains(t, preview, "plans")
+ assert.Contains(t, preview, "todos", "the preview must include both Todos and Plans")
+ })
+ }
+}
+
func TestRenderLayoutPreviewPositions(t *testing.T) {
t.Parallel()
diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go
index c01c028b89..aad1f2364d 100644
--- a/pkg/tui/handlers.go
+++ b/pkg/tui/handlers.go
@@ -957,10 +957,14 @@ func (m *appModel) handleApplySettings(msg messages.ApplySettingsMsg) (tea.Model
// applyLayoutSettings applies the given layout to every chat page (all tabs
// share the same layout) without persisting it.
func (m *appModel) applyLayoutSettings(settings messages.LayoutSettings) (tea.Model, tea.Cmd) {
+ wasEnabled := m.planSidebarEnabled()
settings.SidebarPosition = messages.ParseSidebarPosition(string(settings.SidebarPosition))
settings.SectionSpacing = messages.ParseSectionSpacing(string(settings.SectionSpacing))
settings.SidebarInfoMode = messages.ParseSidebarInfoMode(string(settings.SidebarInfoMode))
m.layoutSettings = settings
+ if !m.planSidebarEnabled() {
+ m.cancelSidebarPlanEdit()
+ }
var cmds []tea.Cmd
for _, page := range m.chatPages {
@@ -969,6 +973,9 @@ func (m *appModel) applyLayoutSettings(settings messages.LayoutSettings) (tea.Mo
}
}
cmds = append(cmds, m.resizeAll())
+ if !wasEnabled && m.planSidebarEnabled() {
+ cmds = append(cmds, m.refreshPlanSidebarCmd())
+ }
return m, tea.Batch(cmds...)
}
@@ -984,6 +991,7 @@ func layoutSettingsFromConfig(l userconfig.LayoutSettings) messages.LayoutSettin
HideUsage: l.HideUsage,
HideAgents: l.HideAgents,
HideTools: l.HideTools,
+ ShowPlans: l.ShowPlans,
HideTodos: l.HideTodos,
}
}
@@ -1048,6 +1056,7 @@ func savePreferences(p messages.Preferences) error {
s.Layout = &userconfig.LayoutSettings{
SidebarPosition: position, SectionSpacing: spacing, SidebarInfoMode: infoMode,
ActiveAgentsOnly: layout.ActiveAgentsOnly,
+ ShowPlans: layout.ShowPlans,
HideSessionPath: layout.HideSessionPath, HideUsage: layout.HideUsage,
HideAgents: layout.HideAgents, HideTools: layout.HideTools, HideTodos: layout.HideTodos,
}
diff --git a/pkg/tui/messages/layout_test.go b/pkg/tui/messages/layout_test.go
index b55f6d8200..4368f50675 100644
--- a/pkg/tui/messages/layout_test.go
+++ b/pkg/tui/messages/layout_test.go
@@ -2,6 +2,18 @@ package messages
import "testing"
+func TestLayoutSettingsDefaults(t *testing.T) {
+ t.Parallel()
+
+ layout := LayoutSettings{}
+ if layout.ShowPlans {
+ t.Error("the Plans section must be hidden by default")
+ }
+ if layout.HideSessionPath || layout.HideUsage || layout.HideAgents || layout.HideTools || layout.HideTodos {
+ t.Error("existing sidebar sections must remain visible by default")
+ }
+}
+
func TestParseSectionSpacing(t *testing.T) {
t.Parallel()
diff --git a/pkg/tui/messages/plans.go b/pkg/tui/messages/plans.go
index c94a22cef3..df32944c99 100644
--- a/pkg/tui/messages/plans.go
+++ b/pkg/tui/messages/plans.go
@@ -11,6 +11,20 @@ import "github.com/docker/docker-agent/pkg/plans"
// and a concurrent change surfaces as an actionable conflict instead of a
// silent overwrite.
type (
+ // PlanSidebarDataMsg shares the app-owned metadata snapshot with chat pages.
+ PlanSidebarDataMsg struct {
+ Result plans.ListResult
+ Loading bool
+ Err error
+ }
+
+ // EditSidebarPlanMsg preserves the revision displayed by the clicked row.
+ EditSidebarPlanMsg struct {
+ TabID string
+ Ref plans.Ref
+ ExpectedVersion int
+ }
+
// ShowPlanBrowserMsg opens the /plans browser dialog.
ShowPlanBrowserMsg struct{}
diff --git a/pkg/tui/messages/settings.go b/pkg/tui/messages/settings.go
index 8f957a6c33..353d434150 100644
--- a/pkg/tui/messages/settings.go
+++ b/pkg/tui/messages/settings.go
@@ -90,8 +90,8 @@ func ParseSidebarInfoMode(raw string) SidebarInfoMode {
// LayoutSettings describes the user-customizable TUI layout: where the
// sidebar sits, which of its optional sections are rendered, how much
// space separates them, and how the Agents section renders each agent.
-// The zero value is the default layout (sidebar on the right, everything
-// visible, normal spacing, compact agent info, full team roster).
+// The zero value is the default layout (sidebar on the right, all sections
+// except Plans visible, normal spacing, compact agent info, full team roster).
type LayoutSettings struct {
SidebarPosition SidebarPosition
SectionSpacing SectionSpacing
@@ -104,6 +104,7 @@ type LayoutSettings struct {
HideUsage bool
HideAgents bool
HideTools bool
+ ShowPlans bool
HideTodos bool
}
diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go
index f1341693cf..53949de9ff 100644
--- a/pkg/tui/page/chat/chat.go
+++ b/pkg/tui/page/chat/chat.go
@@ -477,6 +477,7 @@ func WithInterruptMode(mode msgtypes.InterruptMode) PageOption {
// sectionVisibility maps layout settings to the sidebar's visibility config.
func sectionVisibility(settings msgtypes.LayoutSettings) sidebar.SectionVisibility {
return sidebar.SectionVisibility{
+ ShowPlans: settings.ShowPlans,
HideSessionPath: settings.HideSessionPath,
HideUsage: settings.HideUsage,
HideAgents: settings.HideAgents,
@@ -560,6 +561,10 @@ func (p *chatPage) update(msg tea.Msg) (layout.Model, tea.Cmd) {
// update's timers may be collected after it.
p.pendingTimers = nil
switch msg := msg.(type) {
+ case msgtypes.PlanSidebarDataMsg:
+ updated, cmd := p.sidebar.Update(msg)
+ p.sidebar = updated.(sidebar.Model)
+ return p, cmd
case tea.WindowSizeMsg:
cmd := p.SetSize(msg.Width, msg.Height)
return p, cmd
diff --git a/pkg/tui/page/chat/hittest.go b/pkg/tui/page/chat/hittest.go
index 01c76635dc..4b0a9cd866 100644
--- a/pkg/tui/page/chat/hittest.go
+++ b/pkg/tui/page/chat/hittest.go
@@ -16,6 +16,9 @@ const (
TargetSidebarTitle
TargetSidebarWorkingDir
TargetSidebarAgent
+ TargetSidebarPlan
+ TargetSidebarPlanBrowser
+ TargetSidebarPlanRefresh
TargetSidebarUsageContext
TargetSidebarUsage
TargetSidebarContent
@@ -28,6 +31,7 @@ const (
type HitTest struct {
page *chatPage
AgentName string // populated when At() returns TargetSidebarAgent
+ PlanName string
}
// NewHitTest creates a hit tester for the given chat page.
@@ -116,6 +120,13 @@ func (h *HitTest) sidebarClickTarget(x, y int) MouseTarget {
case sidebar.ClickAgent:
h.AgentName = agentName
return TargetSidebarAgent
+ case sidebar.ClickPlan:
+ h.PlanName = agentName
+ return TargetSidebarPlan
+ case sidebar.ClickPlanBrowser:
+ return TargetSidebarPlanBrowser
+ case sidebar.ClickPlanRefresh:
+ return TargetSidebarPlanRefresh
case sidebar.ClickUsageContext:
return TargetSidebarUsageContext
case sidebar.ClickUsage:
diff --git a/pkg/tui/page/chat/input_handlers.go b/pkg/tui/page/chat/input_handlers.go
index bbca6d4217..a92649c81e 100644
--- a/pkg/tui/page/chat/input_handlers.go
+++ b/pkg/tui/page/chat/input_handlers.go
@@ -155,6 +155,18 @@ func (p *chatPage) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cm
if cmd := p.agentClickCmd(hit.AgentName, msg.Button, msg.Mod); cmd != nil {
return p, cmd
}
+ case TargetSidebarPlan:
+ if msg.Button == tea.MouseLeft {
+ return p, p.sidebar.EditPlan(hit.PlanName, p.routingID)
+ }
+ case TargetSidebarPlanBrowser:
+ if msg.Button == tea.MouseLeft {
+ return p, core.CmdHandler(msgtypes.ShowPlanBrowserMsg{})
+ }
+ case TargetSidebarPlanRefresh:
+ if msg.Button == tea.MouseLeft {
+ return p, core.CmdHandler(msgtypes.RefreshPlansMsg{})
+ }
case TargetSidebarUsageContext:
if msg.Button == tea.MouseLeft {
diff --git a/pkg/tui/page/chat/plans_test.go b/pkg/tui/page/chat/plans_test.go
new file mode 100644
index 0000000000..77d654225f
--- /dev/null
+++ b/pkg/tui/page/chat/plans_test.go
@@ -0,0 +1,89 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/plans"
+ msgtypes "github.com/docker/docker-agent/pkg/tui/messages"
+ "github.com/docker/docker-agent/pkg/tui/styles"
+)
+
+func TestPlanSidebar_ClickRoutesDirectEdit(t *testing.T) {
+ t.Parallel()
+ for _, position := range []msgtypes.SidebarPosition{msgtypes.SidebarLeft, msgtypes.SidebarRight} {
+ t.Run(string(position), func(t *testing.T) {
+ t.Parallel()
+ p := newLayoutTestPage(t, position)
+ p.SetRoutingID("origin")
+ p.SetLayoutSettings(msgtypes.LayoutSettings{
+ SidebarPosition: position, ShowPlans: true,
+ HideUsage: true, HideAgents: true, HideTools: true, HideTodos: true,
+ })
+ p.SetSize(160, 40)
+ p.Update(msgtypes.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{
+ {Name: "release", Version: new(7)},
+ }}})
+ sl := p.computeSidebarLayout()
+ lines := strings.Split(ansi.Strip(p.sidebar.View()), "\n")
+ found := false
+ for y, line := range lines {
+ if !strings.Contains(line, "release") {
+ continue
+ }
+ found = true
+ x := styles.AppPadding + sl.sidebarStartX + strings.Index(line, "release")
+ hit := NewHitTest(p)
+ require.Equal(t, TargetSidebarPlan, hit.At(x, y))
+ assert.Equal(t, "release", hit.PlanName)
+ _, cmd := p.handleMouseClick(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ require.NotNil(t, cmd)
+ msg, ok := cmd().(msgtypes.EditSidebarPlanMsg)
+ require.True(t, ok)
+ assert.Equal(t, plans.SharedRef("release"), msg.Ref)
+ assert.Equal(t, 7, msg.ExpectedVersion)
+ assert.Equal(t, "origin", msg.TabID)
+ _, cmd = p.handleMouseClick(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseRight})
+ assert.Nil(t, cmd)
+ }
+ assert.True(t, found)
+ })
+ }
+}
+
+func TestPlanSidebar_BandClickOpensBrowser(t *testing.T) {
+ t.Parallel()
+ for _, position := range []msgtypes.SidebarPosition{msgtypes.SidebarTop, msgtypes.SidebarBottom} {
+ t.Run(string(position), func(t *testing.T) {
+ t.Parallel()
+ p := newLayoutTestPage(t, position)
+ p.SetLayoutSettings(msgtypes.LayoutSettings{
+ SidebarPosition: position, ShowPlans: true, HideAgents: true, HideTools: true, HideTodos: true,
+ })
+ p.SetSize(90, 40)
+ p.Update(msgtypes.PlanSidebarDataMsg{Result: plans.ListResult{Plans: []plans.Plan{{Name: "release"}}}})
+ sl := p.computeSidebarLayout()
+ found := false
+ for row, line := range strings.Split(ansi.Strip(p.sidebar.View()), "\n") {
+ if !strings.Contains(line, "Plans (1)") {
+ continue
+ }
+ found = true
+ x, y := styles.AppPadding+strings.Index(line, "Plans"), sl.bandY()+row
+ if sl.bandAtBottom {
+ y++
+ }
+ require.Equal(t, TargetSidebarPlanBrowser, NewHitTest(p).At(x, y))
+ _, cmd := p.handleMouseClick(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ require.NotNil(t, cmd)
+ assert.IsType(t, msgtypes.ShowPlanBrowserMsg{}, cmd())
+ }
+ assert.True(t, found)
+ })
+ }
+}
diff --git a/pkg/tui/plans.go b/pkg/tui/plans.go
index 4efd9d8cb2..3b05d9b5c9 100644
--- a/pkg/tui/plans.go
+++ b/pkg/tui/plans.go
@@ -162,10 +162,16 @@ func (m *appModel) planRefreshCmd(notifyWarnings bool) tea.Cmd {
return nil
}
m.planRefreshInFlight = true
+ var sidebarCmd tea.Cmd
+ if m.planSidebarEnabled() {
+ data := m.planSidebarData
+ data.Loading = true
+ sidebarCmd = m.updatePlanSidebar(data)
+ }
svc, ctx := m.plansService(), m.ctx()
timeout := m.planReadTimeoutOrDefault()
refs := m.openPlanDetailRefs()
- return func() tea.Msg {
+ read := func() tea.Msg {
// One shared deadline for the whole reload: however wedged storage
// is, the result always lands and clears the in-flight flag, so the
// refresh pipeline can never get stuck.
@@ -179,6 +185,7 @@ func (m *appModel) planRefreshCmd(notifyWarnings bool) tea.Cmd {
}
return msg
}
+ return tea.Batch(sidebarCmd, read)
}
// appendPlanRefreshCmd appends a coalesced refresh (without warning
@@ -208,15 +215,25 @@ func (m *appModel) appendPlanRefreshCmd(cmds []tea.Cmd) []tea.Cmd {
func (m *appModel) handlePlanRefreshed(msg planRefreshedMsg) (tea.Model, tea.Cmd) {
m.planRefreshInFlight = false
- if !m.planDialogOpen() {
+ if !m.planDataVisible() {
m.planRefreshQueued = false
m.planRefreshQueuedWarnings = false
+ m.planSidebarData.Loading = false
return m, nil
}
var cmds []tea.Cmd
+ data := m.planSidebarData
+ data.Loading = false
+ data.Err = msg.listErr
+ if msg.listErr == nil {
+ data.Result = msg.list
+ }
+ cmds = append(cmds, m.updatePlanSidebar(data))
if msg.listErr != nil {
- cmds = append(cmds, m.planReadFailureCmd(msg.listErr))
+ if m.planDialogOpen() || msg.notifyWarnings {
+ cmds = append(cmds, m.planReadFailureCmd(msg.listErr))
+ }
} else {
cmds = append(cmds, core.CmdHandler(dialog.PlanBrowserDataMsg{Result: msg.list}))
if msg.notifyWarnings {
@@ -543,10 +560,15 @@ func (m *appModel) handleCreatePlan(name string) (tea.Model, tea.Cmd) {
// command — wedged storage or a slow disk must never stall Update — and the
// outcome reports back as a planEditReadyMsg.
func (m *appModel) handleEditPlan(msg messages.EditPlanMsg) (tea.Model, tea.Cmd) {
+ cmd := m.preparePlanEdit(msg, 0)
+ return m, cmd
+}
+
+func (m *appModel) preparePlanEdit(msg messages.EditPlanMsg, sidebarRequest uint64) tea.Cmd {
svc, ctx := m.plansService(), m.ctx()
timeout := m.planReadTimeoutOrDefault()
- return m, func() tea.Msg {
- ready := planEditReadyMsg{ref: msg.Ref, expectedVersion: msg.ExpectedVersion}
+ return func() tea.Msg {
+ ready := planEditReadyMsg{ref: msg.Ref, expectedVersion: msg.ExpectedVersion, sidebarRequest: sidebarRequest}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
p, err := svc.Get(ctx, msg.Ref)
@@ -569,6 +591,7 @@ func (m *appModel) handleEditPlan(msg messages.EditPlanMsg) (tea.Model, tea.Cmd)
// the plan's current version and, when it still matches the version the
// user saw, the draft file seeded with the plan's content.
type planEditReadyMsg struct {
+ sidebarRequest uint64
ref plans.Ref
expectedVersion int
// currentVersion is the version read from storage. When it differs from
@@ -586,16 +609,37 @@ type planEditReadyMsg struct {
// handlePlanEditReady launches the external editor over the prepared draft,
// or surfaces why there is nothing to edit.
func (m *appModel) handlePlanEditReady(msg planEditReadyMsg) (tea.Model, tea.Cmd) {
+ fromSidebar := msg.sidebarRequest != 0
+ if fromSidebar {
+ if msg.sidebarRequest != m.sidebarPlanEditGeneration || !m.sidebarPlanEditInFlight ||
+ !m.planSidebarEnabled() || m.dialogMgr.Open() {
+ if msg.draftPath != "" {
+ _ = os.Remove(msg.draftPath)
+ }
+ return m, nil
+ }
+ if msg.err != nil || msg.draftErr != nil || msg.currentVersion != msg.expectedVersion {
+ m.sidebarPlanEditInFlight = false
+ }
+ }
if msg.err != nil {
cmd := m.planReadFailureCmd(msg.err)
+ var missing *plans.NotFoundError
+ if fromSidebar && errors.As(msg.err, &missing) {
+ cmd = tea.Batch(cmd, m.refreshPlanSidebarCmd())
+ }
return m, cmd
}
// The plan moved on since the version on screen: refresh instead of
// editing a base the user has not seen.
if msg.currentVersion != msg.expectedVersion {
+ retry := "press e again"
+ if fromSidebar {
+ retry = "click the plan again"
+ }
cmds := []tea.Cmd{notification.WarningCmd(fmt.Sprintf(
- "Plan %q is at v%d now (you read v%d). Data refreshed — review and press e again.",
- msg.ref.Name, msg.currentVersion, msg.expectedVersion,
+ "Plan %q is at v%d now (you read v%d). Data refreshed — review and %s.",
+ msg.ref.Name, msg.currentVersion, msg.expectedVersion, retry,
))}
cmds = m.appendPlanRefreshCmd(cmds)
return m, tea.Sequence(cmds...)
@@ -606,17 +650,18 @@ func (m *appModel) handlePlanEditReady(msg planEditReadyMsg) (tea.Model, tea.Cmd
// The user closed the plan dialogs while the edit was being prepared:
// taking over the terminal with an editor now would be disruptive. The
// draft holds only the stored content, so removing it loses nothing.
- if !m.planDialogOpen() {
+ if !fromSidebar && !m.planDialogOpen() {
_ = os.Remove(msg.draftPath)
return m, nil
}
- cmd := m.execPlanEditor(planEditorClosedMsg{ref: msg.ref, expectedVersion: msg.expectedVersion, path: msg.draftPath})
+ cmd := m.execPlanEditor(planEditorClosedMsg{ref: msg.ref, expectedVersion: msg.expectedVersion, path: msg.draftPath, sidebarRequest: msg.sidebarRequest})
return m, cmd
}
// planEditorClosedMsg reports that the external editor for a plan draft has
// exited; the app model then reads the draft and performs the guarded write.
type planEditorClosedMsg struct {
+ sidebarRequest uint64
ref plans.Ref
expectedVersion int
create bool
@@ -661,6 +706,9 @@ func (m *appModel) execPlanEditor(result planEditorClosedMsg) tea.Cmd {
}
func (m *appModel) handlePlanEditorClosed(msg planEditorClosedMsg) (tea.Model, tea.Cmd) {
+ if msg.sidebarRequest != 0 && msg.sidebarRequest == m.sidebarPlanEditGeneration {
+ m.sidebarPlanEditInFlight = false
+ }
if msg.err != nil {
// The editor may have failed after the user saved content (e.g. it
// exited non-zero); the draft is kept so no edit is ever lost.
@@ -893,7 +941,7 @@ func (m *appModel) handlePlanChangedEvent(msg *runtime.PlanChangedEvent) (tea.Mo
}
chatCmd := m.updateChatCmd(msg)
var refresh tea.Cmd
- if m.planDialogOpen() {
+ if m.planDataVisible() {
refresh = m.planRefreshCmd(false)
}
return m, tea.Batch(chatCmd, refresh)
diff --git a/pkg/tui/plans_sidebar.go b/pkg/tui/plans_sidebar.go
new file mode 100644
index 0000000000..47b8204f9c
--- /dev/null
+++ b/pkg/tui/plans_sidebar.go
@@ -0,0 +1,66 @@
+package tui
+
+import (
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/docker/docker-agent/pkg/tui/messages"
+ "github.com/docker/docker-agent/pkg/tui/page/chat"
+)
+
+func (m *appModel) planSidebarEnabled() bool {
+ return m.layoutSettings.ShowPlans && !m.leanMode && !m.hideSidebar
+}
+
+func (m *appModel) planDataVisible() bool {
+ return m.planSidebarEnabled() || m.planDialogOpen()
+}
+
+func (m *appModel) refreshPlanSidebarCmd() tea.Cmd {
+ if !m.planSidebarEnabled() {
+ return nil
+ }
+ return m.planRefreshCmd(false)
+}
+
+func (m *appModel) updatePlanSidebar(data messages.PlanSidebarDataMsg) tea.Cmd {
+ m.planSidebarData = data
+ var cmds []tea.Cmd
+ activeUpdated := false
+ activeID := ""
+ if m.supervisor != nil {
+ activeID = m.supervisor.ActiveID()
+ }
+ for id, page := range m.chatPages {
+ updated, cmd := page.Update(data)
+ m.chatPages[id] = updated.(chat.Page)
+ if id == activeID {
+ m.chatPage = m.chatPages[id]
+ activeUpdated = true
+ }
+ cmds = append(cmds, cmd)
+ }
+ if !activeUpdated && m.chatPage != nil {
+ cmds = append(cmds, m.updateChatCmd(data))
+ }
+ return tea.Batch(cmds...)
+}
+
+func (m *appModel) cancelSidebarPlanEdit() {
+ m.sidebarPlanEditGeneration++
+ m.sidebarPlanEditInFlight = false
+}
+
+func (m *appModel) handleEditSidebarPlan(msg messages.EditSidebarPlanMsg) (tea.Model, tea.Cmd) {
+ if msg.TabID != "" && (m.supervisor == nil || m.supervisor.ActiveID() != msg.TabID) {
+ return m, nil
+ }
+ if !m.planSidebarEnabled() || m.dialogMgr.Open() || m.sidebarPlanEditInFlight {
+ return m, nil
+ }
+ m.sidebarPlanEditGeneration++
+ m.sidebarPlanEditInFlight = true
+ cmd := m.preparePlanEdit(messages.EditPlanMsg{
+ Ref: msg.Ref, ExpectedVersion: msg.ExpectedVersion,
+ }, m.sidebarPlanEditGeneration)
+ return m, cmd
+}
diff --git a/pkg/tui/plans_sidebar_test.go b/pkg/tui/plans_sidebar_test.go
new file mode 100644
index 0000000000..0216fba975
--- /dev/null
+++ b/pkg/tui/plans_sidebar_test.go
@@ -0,0 +1,246 @@
+package tui
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/plans"
+ "github.com/docker/docker-agent/pkg/runtime"
+ "github.com/docker/docker-agent/pkg/session"
+ "github.com/docker/docker-agent/pkg/tui/core/layout"
+ "github.com/docker/docker-agent/pkg/tui/dialog"
+ "github.com/docker/docker-agent/pkg/tui/messages"
+ "github.com/docker/docker-agent/pkg/tui/service/supervisor"
+)
+
+func TestPlanSidebar_DisabledDoesNotLoad(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ name string
+ show, lean, hidden bool
+ }{
+ {name: "default"},
+ {name: "lean", show: true, lean: true},
+ {name: "hidden", show: true, hidden: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ m, _ := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans, m.leanMode, m.hideSidebar = tc.show, tc.lean, tc.hidden
+ assert.Nil(t, m.refreshPlanSidebarCmd())
+ assert.False(t, m.planRefreshInFlight)
+ _, cmd := m.handleEditSidebarPlan(messages.EditSidebarPlanMsg{Ref: plans.SharedRef("p"), ExpectedVersion: 1})
+ assert.Nil(t, cmd)
+ })
+ }
+}
+
+func TestPlanSidebar_EditIntentFromAnotherTabIsIgnored(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "content")
+ m.supervisor = supervisor.New(nil)
+ activeID := m.supervisor.AddSession(t.Context(), nil, session.New(), "", nil)
+ otherID := m.supervisor.AddSession(t.Context(), nil, session.New(), "", nil)
+ require.Equal(t, activeID, m.supervisor.ActiveID())
+ _, cmd := m.Update(messages.EditSidebarPlanMsg{
+ TabID: otherID, Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version,
+ })
+ assert.Nil(t, cmd)
+ assert.False(t, m.sidebarPlanEditInFlight)
+}
+
+func TestPlanSidebar_RefreshWithoutDialogs(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "first")
+ cmd := m.refreshPlanSidebarCmd()
+ assert.True(t, m.planSidebarData.Loading)
+ drainPlanFlow(t, m, cmd)
+ assert.False(t, m.planSidebarData.Loading)
+ require.Len(t, m.planSidebarData.Result.Plans, 1)
+ assert.Equal(t, p.Name, m.planSidebarData.Result.Plans[0].Name)
+ assert.False(t, m.planDialogOpen(), "loading a sidebar must not open the browser")
+
+ _, err := svc.SetStatus(t.Context(), plans.SetStatusRequest{Ref: plans.SharedRef(p.Name), Status: "awaiting-special-review", ExpectedVersion: p.Version})
+ require.NoError(t, err)
+ runPlanFlow(t, m, runtime.PlanChanged("shared", p.Name, "status", 2, ""))
+ assert.Equal(t, "awaiting-special-review", m.planSidebarData.Result.Plans[0].Status)
+}
+
+func TestPlanSidebar_RefreshFailureRetainsRowsAndRecovers(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ mustCreatePlan(t, svc, "release", "first")
+ drainPlanFlow(t, m, m.refreshPlanSidebarCmd())
+ _, cmd := m.handlePlanRefreshed(planRefreshedMsg{listErr: errors.New("offline")})
+ assert.Empty(t, notificationTexts(collectMsgs(cmd)), "background errors live in the section, not repeated toasts")
+ require.Len(t, m.planSidebarData.Result.Plans, 1)
+ require.Error(t, m.planSidebarData.Err)
+ _, cmd = m.handlePlanRefreshed(planRefreshedMsg{listErr: errors.New("offline"), notifyWarnings: true})
+ assert.NotEmpty(t, notificationTexts(collectMsgs(cmd)), "explicit refresh failures notify")
+ drainPlanFlow(t, m, m.refreshPlanSidebarCmd())
+ assert.NoError(t, m.planSidebarData.Err)
+}
+
+func TestPlanSidebar_EditOpensWithoutBrowserAndDeduplicates(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "draft content")
+ blocking := newBlockingReadPlansService(svc)
+ WithPlansService(blocking)(m)
+ msg := messages.EditSidebarPlanMsg{Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version}
+ _, cmd := m.Update(msg)
+ require.NotNil(t, cmd)
+ assert.Zero(t, blocking.readsStarted.Load())
+ _, duplicate := m.Update(msg)
+ assert.Nil(t, duplicate)
+ close(blocking.release)
+ ready, ok := cmd().(planEditReadyMsg)
+ require.True(t, ok)
+ require.NoError(t, ready.err)
+ require.NoError(t, ready.draftErr)
+ t.Cleanup(func() { _ = os.Remove(ready.draftPath) })
+ content, err := os.ReadFile(ready.draftPath)
+ require.NoError(t, err)
+ assert.Equal(t, "draft content", string(content))
+ _, editorCmd := m.Update(ready)
+ require.NotNil(t, editorCmd, "a valid sidebar request must launch the existing editor")
+ assert.Empty(t, notificationTexts(collectMsgs(editorCmd)))
+ assert.False(t, m.planDialogOpen())
+ _, duplicate = m.Update(msg)
+ assert.Nil(t, duplicate, "keep duplicate guard until editor closes")
+ runPlanFlow(t, m, planEditorClosedMsg{ref: ready.ref, expectedVersion: ready.expectedVersion, path: ready.draftPath, sidebarRequest: ready.sidebarRequest})
+ assert.False(t, m.sidebarPlanEditInFlight)
+}
+
+func TestPlanSidebar_CancelledEditDoesNotTakeOverTerminal(t *testing.T) {
+ t.Parallel()
+ for _, cause := range []string{"escape", "disabled", "modal", "generation"} {
+ t.Run(cause, func(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "content")
+ _, cmd := m.Update(messages.EditSidebarPlanMsg{Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version})
+ ready := cmd().(planEditReadyMsg)
+ require.NotEmpty(t, ready.draftPath)
+ t.Cleanup(func() { _ = os.Remove(ready.draftPath) })
+ switch cause {
+ case "escape":
+ m.handleKeyPress(tea.KeyPressMsg{Code: tea.KeyEscape})
+ case "disabled":
+ m.layoutSettings.ShowPlans = false
+ m.cancelSidebarPlanEdit()
+ case "modal":
+ m.Update(dialog.OpenDialogMsg{Model: dialog.NewPlanBrowserDialog(plans.ListResult{})})
+ case "generation":
+ m.cancelSidebarPlanEdit()
+ }
+ _, cmd = m.Update(ready)
+ assert.Nil(t, cmd)
+ _, err := os.Stat(ready.draftPath)
+ assert.True(t, os.IsNotExist(err), "an unused seeded draft must be removed")
+ })
+ }
+}
+
+func TestPlanSidebar_StaleRevisionAndMissingPlan(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "v1")
+ _, err := svc.Update(t.Context(), plans.UpdateRequest{Ref: plans.SharedRef(p.Name), ExpectedVersion: p.Version, Content: "v2"})
+ require.NoError(t, err)
+ msgs := runPlanFlow(t, m, messages.EditSidebarPlanMsg{Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version})
+ assert.Contains(t, strings.Join(notificationTexts(msgs), "\n"), "click the plan again")
+ require.Len(t, m.planSidebarData.Result.Plans, 1)
+ assert.Equal(t, 2, *m.planSidebarData.Result.Plans[0].Version)
+ assert.False(t, m.sidebarPlanEditInFlight)
+ msgs = runPlanFlow(t, m, messages.EditSidebarPlanMsg{Ref: plans.SharedRef("deleted"), ExpectedVersion: 1})
+ assert.Contains(t, strings.Join(notificationTexts(msgs), "\n"), "deleted")
+ assert.False(t, m.sidebarPlanEditInFlight)
+}
+
+func TestPlanSidebar_DisabledDuringRefreshDropsResult(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ mustCreatePlan(t, svc, "release", "content")
+ cmd := m.refreshPlanSidebarCmd()
+ assert.Nil(t, m.refreshPlanSidebarCmd(), "coalesce repeated requests")
+ m.layoutSettings.ShowPlans = false
+ msgs := drainPlanFlow(t, m, cmd)
+ assert.Empty(t, msgs)
+ assert.False(t, m.planRefreshInFlight)
+ assert.False(t, m.planRefreshQueued)
+ assert.False(t, m.planSidebarData.Loading)
+ assert.Empty(t, m.planSidebarData.Result.Plans)
+}
+
+type planSidebarTestPage struct {
+ mockChatPage
+
+ data messages.PlanSidebarDataMsg
+}
+
+func (p *planSidebarTestPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
+ if data, ok := msg.(messages.PlanSidebarDataMsg); ok {
+ p.data = data
+ }
+ return p, nil
+}
+
+func TestPlanSidebar_BackgroundEventsUpdateEveryPage(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "content")
+ sv := supervisor.New(nil)
+ activeID := sv.AddSession(t.Context(), nil, session.New(), "", nil)
+ backgroundID := sv.AddSession(t.Context(), nil, session.New(), "", nil)
+ m.supervisor = sv
+ active, background := &planSidebarTestPage{}, &planSidebarTestPage{}
+ m.chatPages[activeID], m.chatPages[backgroundID] = active, background
+ m.chatPage = active
+ runPlanFlow(t, m, messages.RoutedMsg{
+ SessionID: backgroundID,
+ Inner: runtime.PlanChanged("shared", p.Name, "write", *p.Version, ""),
+ })
+ for _, page := range []*planSidebarTestPage{active, background} {
+ require.Len(t, page.data.Result.Plans, 1)
+ assert.Equal(t, p.Name, page.data.Result.Plans[0].Name)
+ assert.False(t, page.data.Loading)
+ }
+ assert.Same(t, active, m.chatPage)
+ assert.False(t, m.planDialogOpen())
+}
+
+func TestPlanSidebar_CancelledBrowserEditStaysCancelled(t *testing.T) {
+ t.Parallel()
+ m, svc := newPlansTestModel(t)
+ m.layoutSettings.ShowPlans = true
+ p := mustCreatePlan(t, svc, "release", "content")
+ openPlanBrowser(t, m, plans.ListResult{})
+ _, cmd := m.Update(messages.EditPlanMsg{Ref: plans.SharedRef(p.Name), ExpectedVersion: *p.Version})
+ ready := cmd().(planEditReadyMsg)
+ require.Zero(t, ready.sidebarRequest)
+ t.Cleanup(func() { _ = os.Remove(ready.draftPath) })
+ m.Update(dialog.CloseDialogMsg{})
+ _, cmd = m.Update(ready)
+ assert.Nil(t, cmd, "sidebar visibility must not revive an edit requested by the closed browser")
+ _, err := os.Stat(ready.draftPath)
+ assert.True(t, os.IsNotExist(err))
+ _, cmd = m.Update(planDetailLoadedMsg{ref: plans.SharedRef(p.Name), plan: p})
+ assert.Nil(t, cmd, "sidebar visibility must not revive a detail requested by the closed browser")
+}
diff --git a/pkg/tui/settings_persistence_test.go b/pkg/tui/settings_persistence_test.go
index 2ef046243c..42e0474726 100644
--- a/pkg/tui/settings_persistence_test.go
+++ b/pkg/tui/settings_persistence_test.go
@@ -41,6 +41,7 @@ func TestLayoutSettingsFromConfig(t *testing.T) {
HideUsage: true,
HideAgents: true,
HideTools: true,
+ ShowPlans: true,
HideTodos: true,
})
assert.Equal(t, messages.LayoutSettings{
@@ -52,6 +53,7 @@ func TestLayoutSettingsFromConfig(t *testing.T) {
HideUsage: true,
HideAgents: true,
HideTools: true,
+ ShowPlans: true,
HideTodos: true,
}, got)
}
@@ -65,6 +67,7 @@ func TestSaveSettingsToUserConfig_RoundTrip(t *testing.T) {
SidebarInfoMode: messages.InfoModeCompact,
HideSessionPath: true,
HideTools: true,
+ ShowPlans: true,
}
require.NoError(t, saveSettingsToUserConfig(saved, messages.SendModeQueue))
@@ -122,6 +125,7 @@ func TestSavePreferences_RoundTripAndPreservesExtra(t *testing.T) {
SidebarInfoMode: messages.InfoModeDetailed,
ActiveAgentsOnly: true,
HideAgents: true,
+ ShowPlans: true,
},
SendMode: messages.SendModeQueue,
SplitDiffView: false,
@@ -163,7 +167,7 @@ func TestSavePreferences_DefaultsClearEntries(t *testing.T) {
setupSettingsConfigTest(t)
require.NoError(t, savePreferences(messages.Preferences{
- Layout: messages.LayoutSettings{SidebarPosition: messages.SidebarLeft},
+ Layout: messages.LayoutSettings{SidebarPosition: messages.SidebarLeft, ShowPlans: true},
SendMode: messages.SendModeQueue,
SplitDiffView: false,
ExpandThinking: true,
@@ -200,6 +204,55 @@ func TestSavePreferences_DefaultsClearEntries(t *testing.T) {
assert.Zero(t, settings.SoundThreshold)
}
+func TestSavePreferences_ShowPlansRoundTripAndPreservation(t *testing.T) {
+ setupSettingsConfigTest(t)
+
+ require.NoError(t, userconfig.Update(func(cfg *userconfig.Config) error {
+ cfg.Settings = &userconfig.Settings{
+ Theme: "light",
+ Extra: map[string]any{"future_setting": "kept"},
+ }
+ return cfg.SetAlias("dev", &userconfig.Alias{Path: "./dev.yaml"})
+ }))
+
+ saved := messages.LayoutSettings{
+ SidebarPosition: messages.SidebarRight,
+ SectionSpacing: messages.SpacingNormal,
+ SidebarInfoMode: messages.InfoModeCompact,
+ ShowPlans: true,
+ }
+ require.NoError(t, saveSettingsToUserConfig(saved, messages.SendModeSteer))
+
+ cfg, err := userconfig.Load()
+ require.NoError(t, err)
+ layout := cfg.GetSettings().Layout
+ require.NotNil(t, layout, "show_plans alone must keep the layout entry")
+ assert.True(t, layout.ShowPlans)
+ assert.Empty(t, layout.SidebarPosition)
+ assert.Empty(t, layout.SectionSpacing)
+ assert.Empty(t, layout.SidebarInfoMode)
+ assert.Equal(t, saved, layoutSettingsFromConfig(*layout))
+
+ require.NoError(t, saveSettingsToUserConfig(layoutSettingsFromConfig(*layout), messages.SendModeQueue))
+ cfg, err = userconfig.Load()
+ require.NoError(t, err)
+ assert.True(t, cfg.GetSettings().GetLayout().ShowPlans,
+ "changing another preference must preserve Plans")
+ assert.Equal(t, "light", cfg.GetSettings().Theme)
+ assert.Equal(t, "kept", cfg.GetSettings().Extra["future_setting"])
+ require.Contains(t, cfg.Aliases, "dev")
+ assert.Equal(t, "./dev.yaml", cfg.Aliases["dev"].Path)
+
+ saved.ShowPlans = false
+ require.NoError(t, saveSettingsToUserConfig(saved, messages.SendModeSteer))
+ cfg, err = userconfig.Load()
+ require.NoError(t, err)
+ assert.Nil(t, cfg.GetSettings().Layout, "disabling Plans clears an otherwise-default layout")
+ assert.Equal(t, "light", cfg.GetSettings().Theme)
+ assert.Equal(t, "kept", cfg.GetSettings().Extra["future_setting"])
+ require.Contains(t, cfg.Aliases, "dev")
+}
+
func TestSaveSettingsToUserConfig_HideSessionPathKeepsEntry(t *testing.T) {
setupSettingsConfigTest(t)
diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go
index 1bf65c0cba..373b79bbc8 100644
--- a/pkg/tui/tui.go
+++ b/pkg/tui/tui.go
@@ -95,7 +95,10 @@ type appModel struct {
// plansSvc is the host-facing plan service behind /plans. Built lazily
// by plansService() so plan.SharedStorage() only resolves its directory
// after path configuration; tests inject one via WithPlansService.
- plansSvc plans.Service
+ plansSvc plans.Service
+ planSidebarData messages.PlanSidebarDataMsg
+ sidebarPlanEditGeneration uint64
+ sidebarPlanEditInFlight bool
// planMutationTimeout and planReadTimeout override the bounded timeouts
// of plan persistence and plan read commands. Zero means the package
@@ -715,11 +718,13 @@ func (m *appModel) editorOpts() []editor.Option {
// the given app and stores them in the per-session maps under tabID. The active
// convenience pointers (m.chatPage, m.sessionState, m.editor) are also updated.
func (m *appModel) initSessionComponents(tabID string, a *app.App, sess *session.Session) {
+ m.cancelSidebarPlanEdit()
if old := m.chatPages[tabID]; old != nil {
chat.Cleanup(old)
}
ss := service.NewSessionState(sess)
cp := chat.New(m.ar, m.ctx(), a, ss, m.chatPageOpts()...)
+ cp.Update(m.planSidebarData)
cp.SetRoutingID(tabID)
ed := editor.New(m.history, m.editorOpts()...)
@@ -743,6 +748,7 @@ func (m *appModel) initAndFocusComponents() tea.Cmd {
m.editor.Init(),
m.editor.Focus(),
m.resizeAll(),
+ m.refreshPlanSidebarCmd(),
)
}
@@ -758,7 +764,7 @@ func (m *appModel) contextShutdownCmd() tea.Cmd {
// Init initializes the model.
func (m *appModel) Init() tea.Cmd {
- return tea.Batch(m.init(), m.tourStartupCmd(), m.autoThemeInitCmd())
+ return tea.Batch(m.init(), m.tourStartupCmd(), m.autoThemeInitCmd(), m.refreshPlanSidebarCmd())
}
// autoThemeInitCmd enables DEC mode 2031 (terminal color-scheme reports) so
@@ -1122,7 +1128,10 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
// --- Dialog lifecycle ---
- case dialog.OpenDialogMsg, dialog.CloseDialogMsg, dialog.ClosePlanDetailMsg:
+ case dialog.OpenDialogMsg:
+ m.cancelSidebarPlanEdit()
+ return m.forwardDialog(msg)
+ case dialog.CloseDialogMsg, dialog.ClosePlanDetailMsg:
return m.forwardDialog(msg)
case dialog.ExitConfirmedMsg:
@@ -1373,6 +1382,8 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
case messages.EditPlanMsg:
return m.handleEditPlan(msg)
+ case messages.EditSidebarPlanMsg:
+ return m.handleEditSidebarPlan(msg)
case planEditorClosedMsg:
return m.handlePlanEditorClosed(msg)
@@ -1581,7 +1592,7 @@ func (m *appModel) handleRoutedMsg(msg messages.RoutedMsg) (tea.Model, tea.Cmd)
// Shared plans are scope-global: a mutation from a background tab's agent
// must still live-refresh the plan dialogs open on the active tab.
- if _, isPlanChange := msg.Inner.(*runtime.PlanChangedEvent); isPlanChange && m.planDialogOpen() {
+ if _, isPlanChange := msg.Inner.(*runtime.PlanChangedEvent); isPlanChange && m.planDataVisible() {
return m, tea.Batch(page.TakeRoutedTimers(), m.planRefreshCmd(false))
}
return m, page.TakeRoutedTimers()
@@ -1963,6 +1974,7 @@ func (m *appModel) handleSwitchTab(sessionID string) (tea.Model, tea.Cmd) {
if runner == nil {
return m, notification.ErrorCmd("Session not found")
}
+ m.cancelSidebarPlanEdit()
// Now that the switch is committed, finalize the dialog hand-off.
var closeBackgroundDialogCmd tea.Cmd
@@ -2050,6 +2062,7 @@ func (m *appModel) handleSwitchTab(sessionID string) (tea.Model, tea.Cmd) {
if closeBackgroundDialogCmd != nil {
cmds = append(cmds, closeBackgroundDialogCmd)
}
+ cmds = append(cmds, m.refreshPlanSidebarCmd())
return m, tea.Batch(cmds...)
}
@@ -2205,6 +2218,9 @@ func (m *appModel) handleReorderTab(msg messages.ReorderTabMsg) {
// handleCloseTab closes a session tab.
func (m *appModel) handleCloseTab(sessionID string) (tea.Model, tea.Cmd) {
wasActive := sessionID == m.supervisor.ActiveID()
+ if wasActive {
+ m.cancelSidebarPlanEdit()
+ }
// Capture the working dir before closing so we can reuse it if this is the last tab.
var closedWorkingDir string
@@ -2445,6 +2461,10 @@ func (m *appModel) Bindings() []key.Binding {
// handleKeyPress handles all keyboard input with proper priority routing.
func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ if msg.String() == "esc" && m.sidebarPlanEditInFlight {
+ m.cancelSidebarPlanEdit()
+ return m, nil
+ }
// Check if we should stop transcription on Enter or Escape
if m.transcriber.IsRunning() {
switch msg.String() {
diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go
index f5b2d4d8ef..a6fb870f92 100644
--- a/pkg/userconfig/userconfig.go
+++ b/pkg/userconfig/userconfig.go
@@ -146,8 +146,8 @@ type Settings struct {
}
// LayoutSettings customizes the TUI chat layout. The zero value is the
-// default layout: sidebar on the right with every section visible and
-// normal spacing between sections.
+// default layout: sidebar on the right with all sections except Plans visible
+// and normal spacing between sections.
type LayoutSettings struct {
// SidebarPosition places the session info sidebar: "right" (default),
// "left", "top", or "bottom".
@@ -170,6 +170,8 @@ type LayoutSettings struct {
HideAgents bool `yaml:"hide_agents,omitempty"`
// HideTools hides the tools section in the sidebar.
HideTools bool `yaml:"hide_tools,omitempty"`
+ // ShowPlans shows shared plans in the sidebar. Defaults to false.
+ ShowPlans bool `yaml:"show_plans,omitempty"`
// HideTodos hides the todo list section in the sidebar.
HideTodos bool `yaml:"hide_todos,omitempty"`
}
diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go
index 1044a1d5c9..e061cd119a 100644
--- a/pkg/userconfig/userconfig_test.go
+++ b/pkg/userconfig/userconfig_test.go
@@ -240,6 +240,7 @@ func TestSettings_LayoutRoundTrip(t *testing.T) {
ActiveAgentsOnly: true,
HideSessionPath: true,
HideUsage: true,
+ ShowPlans: true,
HideTodos: true,
},
},
@@ -252,6 +253,7 @@ func TestSettings_LayoutRoundTrip(t *testing.T) {
assert.Contains(t, string(data), "hide_session_path: true")
assert.Contains(t, string(data), "sidebar_info_mode: detailed")
assert.Contains(t, string(data), "active_agents_only: true")
+ assert.Contains(t, string(data), "show_plans: true")
loaded, err := loadFrom(configFile, "")
require.NoError(t, err)
@@ -265,6 +267,7 @@ func TestSettings_LayoutRoundTrip(t *testing.T) {
assert.True(t, layout.HideUsage)
assert.False(t, layout.HideAgents)
assert.False(t, layout.HideTools)
+ assert.True(t, layout.ShowPlans)
assert.True(t, layout.HideTodos)
}
@@ -276,6 +279,32 @@ func TestSettings_GetLayoutDefaults(t *testing.T) {
assert.Equal(t, LayoutSettings{}, (&Settings{}).GetLayout())
}
+func TestSettings_ShowPlansDefaults(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ }{
+ {name: "no settings", input: "{}"},
+ {name: "no layout", input: "settings: {}"},
+ {name: "empty layout", input: "settings:\n layout: {}"},
+ {name: "existing layout", input: "settings:\n layout:\n sidebar_position: left"},
+ {name: "explicit false", input: "settings:\n layout:\n show_plans: false"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var cfg Config
+ require.NoError(t, yaml.Unmarshal([]byte(tt.input), &cfg))
+ assert.False(t, cfg.GetSettings().GetLayout().ShowPlans)
+
+ data, err := yaml.Marshal(&cfg)
+ require.NoError(t, err)
+ assert.NotContains(t, string(data), "show_plans:", "the default-off value is omitted")
+ })
+ }
+}
+
func TestConfig_MigrateFromLegacy(t *testing.T) {
t.Parallel()