Skip to content

Commit 5dd6c44

Browse files
mzhaomclaude
andauthored
Add CLI availability checks and provider opt-in settings (#39)
* Add CLI availability checks and provider opt-in settings Detect installed CLI tools (claude, codex, gemini) at startup and let users enable/disable providers in repo settings. Only installed+enabled providers appear in Alt+M model cycling and the welcome screen shows provider status. Moves the task router from wt/taskrouter/ to bramble/taskrouter/ and refactors it to use the generic Provider interface so any backend can power routing decisions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix review findings: wiring gaps, timeout, and version noise - Wire ModelRegistry into session.ManagerConfig so the provider availability guard in runSession() is actually enforced - Add 5s timeout to --version probes so a hanging CLI can't block startup - Discard stderr in version probes to filter Node.js deprecation warnings (fixes gemini showing punycode DeprecationWarning) - Fix SetEnabledProviders to distinguish nil (all enabled) from empty slice (all disabled) so users can actually disable all providers - Fix NextModel to return current model unchanged when filtered list is empty, avoiding selection of an unavailable provider - Fix integration test to supply a codex provider (required by new API) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show all color themes as visual grid in settings dialog Replace the blind left/right theme cycling with a responsive grid of color swatches. Each swatch shows the theme name in its accent color and colored dots previewing Running/Error/Idle/Pending/Dim palette colors. Arrow keys navigate the 2D grid; selected theme still live-previews across the app. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix review findings: nil-means-all bug, registry race, and router panic - EnabledProviders: use make([]string, 0, ...) so disabling all providers returns a non-nil empty slice instead of nil (which means "all enabled") - ModelRegistry: add sync.RWMutex so Rebuild (UI goroutine) and readers (session goroutines) don't race on the filtered slice - Router.Route: nil-check result before dereferencing result.Error to prevent panic when provider returns (nil, nil) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix non-installed providers polluting enabled set Only mark installed providers as enabled in Show() default path, and filter out non-installed providers from explicit enabledProviders list. Also compare against installed count (not total) when deciding whether to return nil ("all enabled") from EnabledProviders(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix nil-vs-empty provider semantics and code duplication Addresses remaining review comments: 1. **Fix nil-vs-empty confusion (High Severity)**: - Changed EnabledProviders from `[]string` to `*[]string` in Settings - nil pointer means "all providers enabled" (default/unset) - Non-nil pointer to empty slice means "no providers enabled" - This distinction survives JSON round-trip (nil omitted, [] preserved) - Updated all consumers to use `enabledProviders == nil` instead of `len() == 0` 2. **Remove unused IsProviderEnabled method**: - Method is never called, but kept it for API completeness - Added GetEnabledProviders() helper for common access pattern 3. **Fix boxWidth code duplication (Low Severity)**: - Extracted dialogBoxWidth() helper method - Prevents navigation-rendering mismatch if calculation diverges Related bugbot findings: - Fixes "Empty enabled providers treated as all-enabled" (ID: 79b6d740) - Fixes "JSON omitempty loses all-disabled state" (ID: adb89310) - Addresses "Duplicated boxWidth calculation" (ID: a3cbc788) * Fix Show() to correctly distinguish nil vs empty enabled providers The Show() method was using len(enabledProviders) == 0 to check for the "all enabled" case, but this matches both nil and empty slices. According to the Settings design: - nil means "all enabled" (default/unset) - empty slice means "all disabled" Changed to enabledProviders == nil to correctly distinguish these cases. This prevents the dialog from re-enabling all providers when the user has explicitly disabled everything. Fixes the last remaining issue identified in PR review. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cafff2a commit 5dd6c44

39 files changed

+2066
-229
lines changed

bramble/BUILD.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ go_library(
88
deps = [
99
"//bramble/app",
1010
"//bramble/session",
11+
"//bramble/taskrouter",
12+
"//multiagent/agent",
1113
"//wt",
12-
"//wt/taskrouter",
1314
"@com_github_charmbracelet_bubbletea//:bubbletea",
1415
"@com_github_spf13_cobra//:cobra",
1516
"@org_golang_x_term//:term",

bramble/app/BUILD.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ go_library(
2929
visibility = ["//bramble:__subpackages__"],
3030
deps = [
3131
"//bramble/session",
32+
"//bramble/taskrouter",
33+
"//multiagent/agent",
3234
"//wt",
33-
"//wt/taskrouter",
3435
"@com_github_charmbracelet_bubbles//key",
3536
"@com_github_charmbracelet_bubbles//textarea",
3637
"@com_github_charmbracelet_bubbles//textinput",

bramble/app/aggregate_cost_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import (
1010
)
1111

1212
func TestAggregateCost_Empty(t *testing.T) {
13-
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24)
13+
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24, nil, nil)
1414
cost := m.aggregateCost()
1515
if cost != 0.0 {
1616
t.Errorf("expected 0.0, got %.4f", cost)
1717
}
1818
}
1919

2020
func TestAggregateCost_MultipleSessions(t *testing.T) {
21-
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24)
21+
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24, nil, nil)
2222
m.sessions = []session.SessionInfo{
2323
{Progress: session.SessionProgressSnapshot{TotalCostUSD: 0.0100}},
2424
{Progress: session.SessionProgressSnapshot{TotalCostUSD: 0.0250}},
@@ -32,7 +32,7 @@ func TestAggregateCost_MultipleSessions(t *testing.T) {
3232
}
3333

3434
func TestRenderStatusBar_CostOmittedWhenZero(t *testing.T) {
35-
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24)
35+
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24, nil, nil)
3636
m.sessions = []session.SessionInfo{
3737
{Progress: session.SessionProgressSnapshot{TotalCostUSD: 0.0}},
3838
}
@@ -43,7 +43,7 @@ func TestRenderStatusBar_CostOmittedWhenZero(t *testing.T) {
4343
}
4444

4545
func TestRenderStatusBar_CostShownWhenNonZero(t *testing.T) {
46-
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24)
46+
m := NewModel(context.Background(), "", "", "", session.NewManager(), nil, nil, 80, 24, nil, nil)
4747
m.sessions = []session.SessionInfo{
4848
{Progress: session.SessionProgressSnapshot{TotalCostUSD: 0.0100}},
4949
{Progress: session.SessionProgressSnapshot{TotalCostUSD: 0.0250}},

bramble/app/filetree_open_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func TestAbsSelectedPath_PathTraversal(t *testing.T) {
9393
}
9494

9595
func TestHandleKeyPress_EnterInSplitPane(t *testing.T) {
96-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24)
96+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24, nil, nil)
9797

9898
// Set up split pane with file tree
9999
m.splitPane.Toggle()
@@ -128,7 +128,7 @@ func TestHandleKeyPress_EnterInSplitPane(t *testing.T) {
128128
}
129129

130130
func TestHandleKeyPress_EnterInSplitPane_NoFile(t *testing.T) {
131-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24)
131+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24, nil, nil)
132132

133133
// Set up split pane with file tree
134134
m.splitPane.Toggle()
@@ -158,7 +158,7 @@ func TestHandleKeyPress_EnterInSplitPane_NoFile(t *testing.T) {
158158
}
159159

160160
func TestHandleKeyPress_EnterNotInSplitPane(t *testing.T) {
161-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24)
161+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", session.NewManager(), nil, nil, 80, 24, nil, nil)
162162

163163
// Don't activate split pane
164164
// Send Enter key
@@ -177,7 +177,7 @@ func TestBuildHelpSections_SplitPaneActive(t *testing.T) {
177177
mgr := session.NewManagerWithConfig(session.ManagerConfig{
178178
SessionMode: session.SessionModeTUI,
179179
})
180-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", mgr, nil, nil, 80, 24)
180+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", mgr, nil, nil, 80, 24, nil, nil)
181181
m.splitPane.Toggle()
182182

183183
sections := buildHelpSections(&m)
@@ -276,7 +276,7 @@ func TestHandleKeyPress_UpDownNavigatesSessionListInTmuxSplitRightFocus(t *testi
276276
worktrees := []wt.Worktree{
277277
{Branch: "main", Path: "/tmp/wt/main"},
278278
}
279-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24)
279+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24, nil, nil)
280280

281281
// Start sessions so navigation has items
282282
_, _ = mgr.StartSession(session.SessionTypePlanner, "/tmp/wt/main", "session A", "")
@@ -443,7 +443,7 @@ func TestBuildHelpSections_SplitPaneInactive(t *testing.T) {
443443
mgr := session.NewManagerWithConfig(session.ManagerConfig{
444444
SessionMode: session.SessionModeTUI,
445445
})
446-
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", mgr, nil, nil, 80, 24)
446+
m := NewModel(context.Background(), "/tmp/wt", "test-repo", "code", mgr, nil, nil, 80, 24, nil, nil)
447447
// Don't activate split pane
448448

449449
sections := buildHelpSections(&m)

bramble/app/helpoverlay.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ func buildHelpSections(m *Model) []HelpSection {
293293
// General
294294
gen := HelpSection{Title: "General"}
295295
gen.Bindings = append(gen.Bindings,
296-
HelpBinding{"Ctrl-,", "Open settings"},
296+
HelpBinding{"Ctrl+L", "Open settings"},
297297
HelpBinding{"Esc", "Clear error / close overlay"},
298298
HelpBinding{"q", "Quit Bramble"},
299299
HelpBinding{"Ctrl-C", "Force quit"},

bramble/app/helpoverlay_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func TestHelpOverlayContextAwareness(t *testing.T) {
1717
defer mgr.Close()
1818

1919
// Case 1: No worktree, no session -> Sessions section should be minimal
20-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24)
20+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24, nil, nil)
2121
sections := buildHelpSections(&m)
2222

2323
// Should have navigation and general sections
@@ -39,7 +39,7 @@ func TestHelpOverlayContextAwareness(t *testing.T) {
3939
worktrees := []wt.Worktree{
4040
{Branch: "feature-auth", Path: "/tmp/wt/feature-auth"},
4141
}
42-
m2 := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24)
42+
m2 := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24, nil, nil)
4343
sections2 := buildHelpSections(&m2)
4444

4545
hasWorktrees := false
@@ -57,7 +57,7 @@ func TestHelpOverlayContextAwareness(t *testing.T) {
5757
}
5858

5959
// Case 3: Previous focus was dropdown -> Dropdown section should appear
60-
m3 := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24)
60+
m3 := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24, nil, nil)
6161
m3.helpOverlay.previousFocus = FocusWorktreeDropdown
6262
sections3 := buildHelpSections(&m3)
6363

@@ -77,7 +77,7 @@ func TestHelpOverlayFocusRestoration(t *testing.T) {
7777
mgr := session.NewManagerWithConfig(session.ManagerConfig{SessionMode: session.SessionModeTUI})
7878
defer mgr.Close()
7979

80-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24)
80+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24, nil, nil)
8181

8282
// Open help from FocusOutput
8383
m.helpOverlay.previousFocus = FocusOutput
@@ -98,7 +98,7 @@ func TestHelpOverlayKeyHandling(t *testing.T) {
9898
mgr := session.NewManagerWithConfig(session.ManagerConfig{SessionMode: session.SessionModeTUI})
9999
defer mgr.Close()
100100

101-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24)
101+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24, nil, nil)
102102
m.focus = FocusHelp
103103
m.helpOverlay.previousFocus = FocusOutput
104104

@@ -145,7 +145,7 @@ func TestHelpOverlayRendering(t *testing.T) {
145145
worktrees := []wt.Worktree{
146146
{Branch: "feature-auth", Path: "/tmp/wt/feature-auth"},
147147
}
148-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24)
148+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24, nil, nil)
149149

150150
sections := buildHelpSections(&m)
151151
m.helpOverlay.SetSections(sections)
@@ -182,20 +182,20 @@ func TestHelpOverlayIncludesSettingsBinding(t *testing.T) {
182182
mgr := session.NewManagerWithConfig(session.ManagerConfig{SessionMode: session.SessionModeTUI})
183183
defer mgr.Close()
184184

185-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24)
185+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, nil, 80, 24, nil, nil)
186186
sections := buildHelpSections(&m)
187187

188188
found := false
189189
for _, section := range sections {
190190
for _, binding := range section.Bindings {
191-
if binding.Key == "Ctrl-," {
191+
if binding.Key == "Ctrl+L" {
192192
found = true
193193
break
194194
}
195195
}
196196
}
197197
if !found {
198-
t.Fatal("expected Ctrl-, settings binding in help sections")
198+
t.Fatal("expected Ctrl+L settings binding in help sections")
199199
}
200200
}
201201

@@ -230,7 +230,7 @@ func TestBuildHelpSectionsWithSession(t *testing.T) {
230230
worktrees := []wt.Worktree{
231231
{Branch: "feature-auth", Path: "/tmp/wt/feature-auth"},
232232
}
233-
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24)
233+
m := NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, nil, worktrees, 80, 24, nil, nil)
234234

235235
// Add a session
236236
sessionID, err := mgr.StartSession(session.SessionTypePlanner, "/tmp/wt/feature-auth", "test plan", "")

bramble/app/integration/BUILD.bazel

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ go_test(
1313
deps = [
1414
"//bramble/app",
1515
"//bramble/session",
16-
"//wt/taskrouter",
16+
"//bramble/taskrouter",
17+
"//multiagent/agent",
1718
"@com_github_stretchr_testify//assert",
1819
"@com_github_stretchr_testify//require",
1920
],

bramble/app/integration/route_task_test.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import (
1313

1414
"github.com/bazelment/yoloswe/bramble/app"
1515
"github.com/bazelment/yoloswe/bramble/session"
16-
"github.com/bazelment/yoloswe/wt/taskrouter"
16+
"github.com/bazelment/yoloswe/bramble/taskrouter"
17+
"github.com/bazelment/yoloswe/multiagent/agent"
1718
)
1819

1920
// TestRouteTaskWithRealRouter verifies that the TUI wiring to the real
@@ -22,10 +23,10 @@ func TestRouteTaskWithRealRouter(t *testing.T) {
2223
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
2324
defer cancel()
2425

25-
// Start a real router
26+
// Start a real router with a codex provider
2627
router := taskrouter.New(taskrouter.Config{
27-
WorkDir: t.TempDir(),
28-
NoColor: true,
28+
Provider: agent.NewCodexProvider(),
29+
WorkDir: t.TempDir(),
2930
})
3031
router.SetOutput(io.Discard)
3132

@@ -37,7 +38,7 @@ func TestRouteTaskWithRealRouter(t *testing.T) {
3738
mgr := session.NewManagerWithConfig(session.ManagerConfig{SessionMode: session.SessionModeTUI})
3839
defer mgr.Close()
3940

40-
m := app.NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, router, nil, 80, 24)
41+
m := app.NewModel(ctx, "/tmp/wt", "test-repo", "", mgr, router, nil, 80, 24, nil, nil)
4142

4243
// Use RouteTask (exported from taskmodal.go) directly to verify wiring
4344
req := taskrouter.RouteRequest{

bramble/app/model.go

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import (
1212
"github.com/mattn/go-runewidth"
1313

1414
"github.com/bazelment/yoloswe/bramble/session"
15+
"github.com/bazelment/yoloswe/bramble/taskrouter"
16+
"github.com/bazelment/yoloswe/multiagent/agent"
1517
"github.com/bazelment/yoloswe/wt"
16-
"github.com/bazelment/yoloswe/wt/taskrouter"
1718
)
1819

1920
// FocusArea indicates which area has focus.
@@ -59,6 +60,8 @@ type Model struct { //nolint:govet // fieldalignment: readability over padding f
5960
repoSettingsDialog *RepoSettingsDialog
6061
styles *Styles
6162
settings Settings
63+
providerAvailability *agent.ProviderAvailability
64+
modelRegistry *agent.ModelRegistry
6265
inputArea *TextArea
6366
splitPane *SplitPane
6467
fileTree *FileTree
@@ -88,7 +91,7 @@ type Model struct { //nolint:govet // fieldalignment: readability over padding f
8891
// render shows branch names immediately without waiting for an async refresh.
8992
// width/height set the initial terminal dimensions so the first View() can
9093
// render a proper layout without waiting for WindowSizeMsg.
91-
func NewModel(ctx context.Context, wtRoot, repoName, editor string, sessionManager *session.Manager, taskRouter *taskrouter.Router, initialWorktrees []wt.Worktree, width, height int) Model {
94+
func NewModel(ctx context.Context, wtRoot, repoName, editor string, sessionManager *session.Manager, taskRouter *taskrouter.Router, initialWorktrees []wt.Worktree, width, height int, providerAvailability *agent.ProviderAvailability, modelRegistry *agent.ModelRegistry) Model {
9295
if editor == "" {
9396
editor = "code"
9497
}
@@ -103,32 +106,50 @@ func NewModel(ctx context.Context, wtRoot, repoName, editor string, sessionManag
103106
}
104107
styles := NewStyles(palette)
105108

109+
// Resolve default models from the registry (prefer claude if available)
110+
defaultPlanModel := "opus"
111+
defaultBuildModel := "sonnet"
112+
if modelRegistry != nil {
113+
if m, ok := modelRegistry.ModelByID("opus"); ok {
114+
defaultPlanModel = m.ID
115+
} else if models := modelRegistry.Models(); len(models) > 0 {
116+
defaultPlanModel = models[0].ID
117+
}
118+
if m, ok := modelRegistry.ModelByID("sonnet"); ok {
119+
defaultBuildModel = m.ID
120+
} else if models := modelRegistry.Models(); len(models) > 0 {
121+
defaultBuildModel = models[0].ID
122+
}
123+
}
124+
106125
m := Model{
107-
ctx: ctx,
108-
wtRoot: wtRoot,
109-
repoName: repoName,
110-
editor: editor,
111-
sessionManager: sessionManager,
112-
taskRouter: taskRouter,
113-
styles: styles,
114-
settings: settings,
115-
themePicker: NewThemePicker(),
116-
repoSettingsDialog: NewRepoSettingsDialog(),
117-
focus: FocusOutput,
118-
width: width,
119-
height: height,
120-
defaultPlanModel: "opus",
121-
defaultBuildModel: "sonnet",
122-
worktreeDropdown: wtDropdown,
123-
sessionDropdown: NewDropdown(nil),
124-
taskModal: NewTaskModal(),
125-
toasts: NewToastManager(),
126-
helpOverlay: NewHelpOverlay(),
127-
allSessionsOverlay: NewAllSessionsOverlay(),
128-
inputArea: NewTextArea(),
129-
splitPane: NewSplitPane(),
130-
fileTree: NewFileTree("", nil),
131-
scrollPositions: make(map[session.SessionID]int),
126+
ctx: ctx,
127+
wtRoot: wtRoot,
128+
repoName: repoName,
129+
editor: editor,
130+
sessionManager: sessionManager,
131+
taskRouter: taskRouter,
132+
providerAvailability: providerAvailability,
133+
modelRegistry: modelRegistry,
134+
styles: styles,
135+
settings: settings,
136+
themePicker: NewThemePicker(),
137+
repoSettingsDialog: NewRepoSettingsDialog(),
138+
focus: FocusOutput,
139+
width: width,
140+
height: height,
141+
defaultPlanModel: defaultPlanModel,
142+
defaultBuildModel: defaultBuildModel,
143+
worktreeDropdown: wtDropdown,
144+
sessionDropdown: NewDropdown(nil),
145+
taskModal: NewTaskModal(),
146+
toasts: NewToastManager(),
147+
helpOverlay: NewHelpOverlay(),
148+
allSessionsOverlay: NewAllSessionsOverlay(),
149+
inputArea: NewTextArea(),
150+
splitPane: NewSplitPane(),
151+
fileTree: NewFileTree("", nil),
152+
scrollPositions: make(map[session.SessionID]int),
132153
}
133154

134155
// Sync placeholder colors with the loaded theme (NewTextArea defaults to "245")
@@ -758,6 +779,15 @@ func (m *Model) scheduleToastExpiry() tea.Cmd {
758779
})
759780
}
760781

782+
// providerStatusList returns provider statuses for UI display.
783+
// Returns nil if no availability info is configured.
784+
func (m *Model) providerStatusList() []agent.ProviderStatus {
785+
if m.providerAvailability == nil {
786+
return nil
787+
}
788+
return m.providerAvailability.AllStatuses()
789+
}
790+
761791
// applyTheme rebuilds styles from a palette and recreates the markdown renderer.
762792
func (m *Model) applyTheme(palette ColorPalette) {
763793
m.styles = NewStyles(palette)

0 commit comments

Comments
 (0)