From f7b10395e8c672c08836758c94192066c0ca78f1 Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 09:13:47 -0800 Subject: [PATCH 1/8] feat: let local-model users set up a custom provider during first-run onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-run provider picker captured all input and had no escape hatch, so users running local models (Ollama, vLLM, llama.cpp) had to authenticate a cloud provider or hand-edit config files outside the TUI. The picker now opens with a "Set up a custom provider" entry that launches the provider wizard directly, esc steps back to the picker, and finishing the wizard refreshes the list so the new provider's models can be picked immediately. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- .gitignore | 1 + docs/INDEX.md | 1 + docs/architecture/PROVIDER_ONBOARDING.md | 127 +++++++++++++++++++ internal/ui/dialog/models.go | 39 +++++- internal/ui/dialog/models_onboarding_test.go | 107 ++++++++++++++++ internal/ui/dialog/provider_wizard.go | 22 +++- internal/ui/dialog/provider_wizard_test.go | 2 +- internal/ui/model/ui.go | 21 ++- 8 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/PROVIDER_ONBOARDING.md create mode 100644 internal/ui/dialog/models_onboarding_test.go diff --git a/.gitignore b/.gitignore index c8421fc..f392948 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ completions/phosphor.*sh # Local planning/scratch artifacts (temporary working docs, never committed) /_scratch/ .gocache/ +/.tmp/ diff --git a/docs/INDEX.md b/docs/INDEX.md index 04fbf4d..2582c8d 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -102,6 +102,7 @@ searchable through the `phosphor_docs` tool or by viewing any | --- | --- | | [architecture/OVERVIEW.md](architecture/OVERVIEW.md) | Module layout and the transport/backend split. | | [architecture/SESSIONS.md](architecture/SESSIONS.md) | The two-tier session model backed by SQLite. | +| [architecture/PROVIDER_ONBOARDING.md](architecture/PROVIDER_ONBOARDING.md) | The first-run provider onboarding flow and the custom (local) provider path. | ### Architecture Decision Records diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md new file mode 100644 index 0000000..2f1cc9c --- /dev/null +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -0,0 +1,127 @@ +# Provider Onboarding Flow + +This document describes what happens the first time Phosphor is started +in a workspace on a machine with no usable provider configuration, and +how the custom (local/self-hosted) provider path fits into it. + +## The gate: `Config.IsConfigured()` + +Everything hinges on one check: + +```go +// pkg/config/config.go +func (c *Config) IsConfigured() bool { + return len(c.EnabledProviders()) > 0 +} +``` + +If no provider is enabled (no global config, no data config, no project +`phosphor.json` with providers), the app refuses to initialize the agent +(`internal/app/app.go`) and the TUI routes straight into onboarding +(`internal/ui/model/ui.go`, `desiredState = uiOnboarding`). + +## The onboarding screen + +Onboarding is **the `Models` dialog rendered in onboarding mode** +(`internal/ui/dialog/models.go`, `NewModels(com, isOnboarding)`). +`UI.Init()` force-opens it whenever the state is `uiOnboarding`. + +Input is captured at two layers: + +1. `handleKeyPressMsg` routes *every* message to the dialog overlay + while `m.dialog.HasDialogs()` is true, so global keys (including + `ctrl+p`, the main command menu) never fire. +2. The dialog itself is inescapable: `esc` returns `ActionClose`, but + `handleDialogMsg` swallows it while the `Models` dialog is the front + dialog in onboarding and re-opens the dialog otherwise. + +Selecting a provider/model chains into the credential dialogs +(`openAuthenticationDialog` → `NewOAuthHyper` / `NewOAuthCopilot` / +`NewAPIKeyInput`). Completing the flow calls `handleSelectModel`, which +flips the state to `uiLanding`, runs `SetupAgents()`, and initializes +the system agent. + +## The chicken-and-egg problem + +The custom provider wizard (`internal/ui/dialog/provider_wizard.go`, +opened via `Ctrl+P` → *Add Custom Provider*) is only reachable from the +main command menu. But the command menu cannot be opened during +onboarding because the onboarding dialog intercepts all input. So a +user running local models (Ollama, vLLM, llama.cpp, LM Studio, …) had +no way to configure their endpoint inside the app — they had to +first authenticate a cloud provider, or hand-edit +`$XDG_DATA_HOME/phosphor/phosphor.json` outside the TUI. + +## The fix: custom provider entry in the onboarding list + +The onboarding provider list now starts with a synthetic +*Set up a custom provider* entry. Selecting it emits +`ActionOpenDialog{DialogID: ProviderWizardID}`, which opens the +existing 4-step wizard (name → base URL → API key → model IDs) directly +on top of the onboarding screen: + +- While the wizard is the front dialog, `esc` closes *it* and returns + to the provider list (it remains swallowed only for the provider + picker itself, so onboarding stays inescapable). This also applies to + the API-key/OAuth dialogs opened on top of the picker — `esc` there + steps back to the provider list instead of doing nothing. +- Finishing the wizard persists the provider to the global data config + (`setupProvider` → `SetConfigField(ScopeGlobal, …)`), then closes the + wizard and refreshes the provider list so the new provider's models + appear immediately. +- The user then picks one of those models; `handleSelectModel` sees the + provider is configured, skips authentication, sets large/small model + preferences (custom providers fall back to the large model as the + small model, `internal/app/app.go`, `GetDefaultSmallModel`), and + leaves onboarding normally. + +## Flow diagram + +```mermaid +flowchart TD + A[phosphor TUI starts] --> B{Config.IsConfigured?} + B -- no --> C[uiOnboarding:\nModels dialog force-opened] + B -- yes --> Z[uiLanding / uiInitialize] + C --> D{User choice} + D -- built-in provider --> E[Credential dialog\nAPI key / OAuth] + D -- custom provider entry --> F[Provider wizard\nname, URL, key, models] + F -- esc --> C + F -- finish --> G[Persist to global data config\nproviders.id.* ] + G --> C + E --> H[handleSelectModel] + C -- select configured model --> H + H --> I[SetupAgents + InitSystemAgent\nstate = uiLanding] +``` + +## Why not open the whole command menu during onboarding? + +Items like Sessions, Skills, Prune, or Codebase Index require the +workspace system agent, which is only initialized after a provider is +selected (`InitSystemAgent` runs at the end of `handleSelectModel`). +Exposing the full menu there would offer actions that cannot run yet. +The single, targeted *Set up a custom provider* entry gives local-model +users the escape hatch without pretending the rest of the app works +before a provider exists. + +## Files involved + +| File | Role | +| --- | --- | +| `internal/ui/model/ui.go` | state machine, dialog routing, `setupProvider`, `openProviderWizardDialog` | +| `internal/ui/dialog/models.go` | provider/model picker (the onboarding screen) | +| `internal/ui/dialog/provider_wizard.go` | custom provider setup wizard | +| `internal/ui/dialog/provider_manager.go` | custom provider management (post-onboarding) | +| `internal/ui/dialog/api_key_input.go`, `oauth.go` | credential capture | +| `pkg/config/config.go`, `pkg/config/load.go`, `pkg/config/store.go` | provider schema, validation, persistence | +| `internal/app/app.go` | `IsConfigured` gate, `GetDefaultSmallModel` fallback | + +## Testing + +`internal/ui/dialog/models_onboarding_test.go` covers the synthetic +entry: presence only during onboarding, that selecting it returns +`ActionOpenDialog{ProviderWizardID}`, and that built-in providers remain +listed alongside it. + +Note: in sandboxes where `~/.local/share/phosphor` is unwritable, set +`PHOSPHOR_DISABLE_PROVIDER_AUTO_UPDATE=1` when running the suite — +otherwise `config.Providers` fails trying to refresh the provider cache. \ No newline at end of file diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index b552973..0099fca 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -67,6 +67,15 @@ const ( smallModelInputPlaceholder = "Choose a model for small, simple tasks" ) +// customProviderItemID is the sentinel provider id used by the synthetic +// "set up a custom provider" entry shown during onboarding. Colons are +// excluded from user-authored provider ids (see providerIDPattern in +// provider_wizard.go), so this id can never collide with a real provider. +const customProviderItemID = catwalk.InferenceProvider("phosphor:custom-provider") + +// customProviderItemName is the label of the synthetic onboarding entry. +const customProviderItemName = "Set up a custom provider (local or self-hosted endpoint)" + // ModelsID is the identifier for the model selection dialog. const ModelsID = "models" @@ -195,6 +204,10 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { break } + if modelItem.prov.ID == customProviderItemID { + return ActionOpenDialog{DialogID: ProviderWizardID} + } + isEdit := key.Matches(msg, m.keyMap.Edit) return ActionSelectModel{ @@ -378,6 +391,24 @@ func (m *Models) setProviderItems() error { // itemsMap contains the keys of added model items. itemsMap := make(map[string]*ModelItem) groups := []ModelGroup{} + + // During onboarding, offer the custom provider wizard at the top of + // the list so local-model users can point the app at their own + // endpoint without first authenticating a cloud provider. + if m.isOnboarding { + customProvider := catwalk.Provider{ + ID: customProviderItemID, + Name: "Custom Provider", + } + customItem := NewModelItem( + t, + customProvider, + catwalk.Model{ID: customProviderItemName, Name: customProviderItemName}, + m.modelType, + false, + ) + groups = append(groups, NewModelGroup(t, "Custom Provider", false, customItem)) + } for id, p := range cfg.Providers.Seq2() { if p.Disable { continue @@ -489,7 +520,13 @@ func (m *Models) setProviderItems() error { } if len(recentGroup.Items) > 0 { - groups = append([]ModelGroup{recentGroup}, groups...) + customIdx := 0 + if m.isOnboarding { + // Keep the custom provider entry above recently used + // models, which belong to previously configured providers. + customIdx = 1 + } + groups = append([]ModelGroup{recentGroup}, groups[customIdx:]...) } } diff --git a/internal/ui/dialog/models_onboarding_test.go b/internal/ui/dialog/models_onboarding_test.go new file mode 100644 index 0000000..5dfa84a --- /dev/null +++ b/internal/ui/dialog/models_onboarding_test.go @@ -0,0 +1,107 @@ +package dialog + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/hackafterdark/phosphor/internal/ui/common" + "github.com/hackafterdark/phosphor/internal/ui/list" + "github.com/hackafterdark/phosphor/internal/ui/styles" + "github.com/hackafterdark/phosphor/pkg/config" + "github.com/hackafterdark/phosphor/pkg/csync" + "github.com/stretchr/testify/require" +) + +func TestOnboardingCustomProviderEntry(t *testing.T) { + t.Parallel() + + t.Run("shown at top of list during onboarding", func(t *testing.T) { + t.Parallel() + + m := newTestModels(t, true) + items := visibleSelectable(m) + require.NotEmpty(t, items) + first, ok := items[0].(*ModelItem) + require.True(t, ok) + require.Equal(t, customProviderItemID, first.prov.ID) + }) + + t.Run("selecting it opens the provider wizard", func(t *testing.T) { + t.Parallel() + + m := newTestModels(t, true) + m.list.SelectFirst() + action := m.HandleMsg(selectKeyMsg()) + open, ok := action.(ActionOpenDialog) + require.True(t, ok, "expected ActionOpenDialog, got %T", action) + require.Equal(t, ProviderWizardID, open.DialogID) + }) + + t.Run("not shown outside onboarding", func(t *testing.T) { + t.Parallel() + + m := newTestModels(t, false) + for _, item := range visibleSelectable(m) { + mi, ok := item.(*ModelItem) + if !ok { + continue + } + require.NotEqual(t, customProviderItemID, mi.prov.ID) + } + }) + + t.Run("real models are unaffected", func(t *testing.T) { + t.Parallel() + + m := newTestModels(t, true) + var found bool + for _, item := range visibleSelectable(m) { + mi, ok := item.(*ModelItem) + if ok && mi.prov.ID != customProviderItemID { + found = true + break + } + } + require.True(t, found, "built-in providers must still be listed") + }) +} + +func newTestModels(t *testing.T, isOnboarding bool) *Models { + t.Helper() + + originalUseMock := config.UseMockProviders + config.UseMockProviders = true + t.Cleanup(func() { + config.UseMockProviders = originalUseMock + config.ResetProviders() + }) + config.ResetProviders() + + s := styles.CharmtonePantera() + cfg := &config.Config{ + Options: &config.Options{DisableProviderAutoUpdate: true}, + Providers: csync.NewMap[string, config.ProviderConfig](), + } + com := &common.Common{ + Styles: &s, + Workspace: &mockWorkspace{cfg: cfg}, + } + m, err := NewModels(com, isOnboarding) + require.NoError(t, err) + return m +} + +func visibleSelectable(m *Models) []list.Item { + items := m.list.VisibleItems() + var out []list.Item + for _, item := range items { + if _, ok := item.(*ModelItem); ok { + out = append(out, item) + } + } + return out +} + +func selectKeyMsg() tea.KeyPressMsg { + return tea.KeyPressMsg{Code: tea.KeyEnter} +} diff --git a/internal/ui/dialog/provider_wizard.go b/internal/ui/dialog/provider_wizard.go index 8492ccf..221737c 100644 --- a/internal/ui/dialog/provider_wizard.go +++ b/internal/ui/dialog/provider_wizard.go @@ -49,9 +49,10 @@ type ActionSetupProvider struct { // an optional API key, and a list of model ids, then hands everything to // the UI model to write to the global config as a custom provider. type ProviderWizard struct { - com *common.Common - width int - step providerWizardStep + com *common.Common + isOnboarding bool + width int + step providerWizardStep providerID string baseURL string @@ -75,10 +76,13 @@ type ProviderWizard struct { var _ Dialog = (*ProviderWizard)(nil) -// NewProviderWizard creates a new provider setup wizard dialog. -func NewProviderWizard(com *common.Common) *ProviderWizard { +// NewProviderWizard creates a new provider setup wizard dialog. When +// isOnboarding is true the wizard renders in the bottom-left onboarding +// style (unframed) instead of as a centered dialog. +func NewProviderWizard(com *common.Common, isOnboarding bool) *ProviderWizard { m := &ProviderWizard{} m.com = com + m.isOnboarding = isOnboarding m.width = 60 innerWidth := m.width - com.Styles.Dialog.View.GetHorizontalFrameSize() - 2 // (1) cursor padding @@ -300,7 +304,13 @@ func (m *ProviderWizard) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { view := dialogStyle.Render(strings.Join(lines, "\n")) cur := m.Cursor() - DrawCenterCursor(scr, area, view, cur) + if m.isOnboarding { + view = strings.Join(lines, "\n") + cur = adjustOnboardingInputCursor(t, cur) + DrawOnboardingCursor(scr, area, view, cur) + } else { + DrawCenterCursor(scr, area, view, cur) + } return cur } diff --git a/internal/ui/dialog/provider_wizard_test.go b/internal/ui/dialog/provider_wizard_test.go index 54e7156..420db34 100644 --- a/internal/ui/dialog/provider_wizard_test.go +++ b/internal/ui/dialog/provider_wizard_test.go @@ -13,7 +13,7 @@ import ( func newTestWizard(t *testing.T) *ProviderWizard { t.Helper() s := styles.CharmtonePantera() - return NewProviderWizard(&common.Common{Styles: &s}) + return NewProviderWizard(&common.Common{Styles: &s}, false) } func TestNormalizeBaseURL(t *testing.T) { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 34407c3..e4bda60 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1858,7 +1858,12 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { // Generic dialog messages case dialog.ActionClose: if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) { - break + // The provider picker itself is inescapable during + // onboarding, but esc on any dialog opened on top of it + // (e.g. the custom provider wizard) returns to the picker. + if front := m.dialog.DialogLast(); front != nil && front.ID() == dialog.ModelsID { + break + } } if m.dialog.ContainsDialog(dialog.FilePickerID) { @@ -4857,7 +4862,7 @@ func (m *UI) openProviderWizardDialog() tea.Cmd { return nil } - m.dialog.OpenDialog(dialog.NewProviderWizard(m.com)) + m.dialog.OpenDialog(dialog.NewProviderWizard(m.com, m.state == uiOnboarding)) return nil } @@ -4899,6 +4904,18 @@ func (m *UI) setupProvider(msg dialog.ActionSetupProvider) tea.Cmd { } m.dialog.CloseDialog(dialog.ProviderWizardID) + if m.state == uiOnboarding { + // Refresh the onboarding provider list so the new provider and + // its models appear immediately for the user to pick from. + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.openModelsDialog(); cmd != nil { + return cmd + } + return util.ReportInfo(fmt.Sprintf( + "Provider %q added. Pick one of its models to continue.", + msg.ProviderID, + )) + } return util.ReportInfo(fmt.Sprintf( "Provider %q (%s) added to the global config. Press Ctrl+L to switch models.", msg.ProviderID, msg.BaseURL, From 37d29de8c27e41661a4385334b7105ec3c8e45cd Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 10:00:54 -0800 Subject: [PATCH 2/8] feat: collect context window and attachment support in the provider wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom providers had no way to declare context_window or supports_attachments during setup, leaving context compaction disabled for local models since summarization is skipped when the window is zero. The wizard gains an optional capabilities step that writes both keys onto every configured model; skipping it keeps the previous behavior. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- docs/architecture/PROVIDER_ONBOARDING.md | 11 ++- internal/ui/dialog/provider_wizard.go | 99 ++++++++++++++++++++-- internal/ui/dialog/provider_wizard_test.go | 64 ++++++++++++-- internal/ui/model/ui.go | 9 +- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md index 2f1cc9c..b80e569 100644 --- a/docs/architecture/PROVIDER_ONBOARDING.md +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -57,8 +57,15 @@ first authenticate a cloud provider, or hand-edit The onboarding provider list now starts with a synthetic *Set up a custom provider* entry. Selecting it emits `ActionOpenDialog{DialogID: ProviderWizardID}`, which opens the -existing 4-step wizard (name → base URL → API key → model IDs) directly -on top of the onboarding screen: +existing 5-step wizard (name → base URL → API key → model IDs → +capabilities) directly on top of the onboarding screen. The +capabilities step is optional: it collects `context_window` (written to +every model so compaction knows the budget) and +`supports_attachments` (written to every model only when explicitly +answered). Leaving it empty writes neither field — see +`shouldSummarize` in `pkg/agent/agent.go`, which skips summarization +when the window is zero, so an unset window silently disables context +compaction: - While the wizard is the front dialog, `esc` closes *it* and returns to the provider list (it remains swallowed only for the provider diff --git a/internal/ui/dialog/provider_wizard.go b/internal/ui/dialog/provider_wizard.go index 221737c..ed69e45 100644 --- a/internal/ui/dialog/provider_wizard.go +++ b/internal/ui/dialog/provider_wizard.go @@ -5,6 +5,7 @@ import ( "net/url" "regexp" "slices" + "strconv" "strings" "charm.land/bubbles/v2/help" @@ -27,8 +28,13 @@ const ( providerWizardStepURL providerWizardStepKey providerWizardStepModels + providerWizardStepCaps ) +// providerWizardStepCount is the total number of wizard screens, used in +// the "Step X of N" footer. +const providerWizardStepCount = int(providerWizardStepCaps) + 1 + // providerIDPattern is the accepted character set for a provider id. Dots // are deliberately excluded because config writes use dotted key paths // (providers..base_url). @@ -38,10 +44,12 @@ var providerIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // the flow. The caller is responsible for persisting the provider to the // global config and closing the dialog. type ActionSetupProvider struct { - ProviderID string - BaseURL string - APIKey string - Models []string + ProviderID string + BaseURL string + APIKey string + Models []string + ContextW int64 + Attachments *bool } // ProviderWizard is a small linear wizard that collects an @@ -58,6 +66,8 @@ type ProviderWizard struct { baseURL string apiKey string models []string + contextW int64 + attach *bool errText string @@ -65,6 +75,7 @@ type ProviderWizard struct { urlInput textinput.Model keyInput textinput.Model modelInput textinput.Model + capInput textinput.Model help help.Model keyMap struct { @@ -91,6 +102,7 @@ func NewProviderWizard(com *common.Common, isOnboarding bool) *ProviderWizard { m.urlInput = newWizardInput(com, innerWidth, "http://localhost:11434/v1") m.keyInput = newWizardInput(com, innerWidth, "leave empty if the server doesn't need one") m.modelInput = newWizardInput(com, innerWidth, "e.g. qwen3-32b") + m.capInput = newWizardInput(com, innerWidth, "e.g. 32768 y (leave empty to skip)") m.nameInput.Focus() m.help = help.New() @@ -137,6 +149,19 @@ func (m *ProviderWizard) HandleMsg(msg tea.Msg) Action { } return nil case key.Matches(msg, m.keyMap.Submit): + if m.step == providerWizardStepCaps { + return m.advance() + } + // On the model-ID screen an empty input means "done adding + // models": move to the capabilities screen. With no models + // collected yet, fall through to advance() so the required + // list error surfaces instead of silently skipping. + if m.step == providerWizardStepModels && strings.TrimSpace(m.modelInput.Value()) == "" && len(m.models) > 0 { + m.step = providerWizardStepCaps + m.errText = "" + m.focusStep() + return nil + } return m.advance() default: // Backspace on an empty input steps back a screen. @@ -173,8 +198,10 @@ func (m *ProviderWizard) currentInput() *textinput.Model { return &m.urlInput case providerWizardStepKey: return &m.keyInput - default: + case providerWizardStepModels: return &m.modelInput + default: + return &m.capInput } } @@ -183,6 +210,7 @@ func (m *ProviderWizard) focusStep() { m.urlInput.Blur() m.keyInput.Blur() m.modelInput.Blur() + m.capInput.Blur() m.currentInput().Focus() } @@ -233,16 +261,57 @@ func (m *ProviderWizard) advance() Action { m.errText = "Add at least one model id (press enter after typing it)." return nil } - return ActionSetupProvider{ + m.step = providerWizardStepCaps + m.focusStep() + case providerWizardStepCaps: + action := ActionSetupProvider{ ProviderID: m.providerID, BaseURL: m.baseURL, APIKey: m.apiKey, Models: slices.Clone(m.models), } + if raw := strings.TrimSpace(m.capInput.Value()); raw != "" { + contextW, attach, err := parseCapabilities(raw) + if err != nil { + m.errText = err.Error() + return nil + } + action.ContextW = contextW + action.Attachments = attach + m.contextW = contextW + m.attach = attach + } + return action } return nil } +// parseCapabilities parses the optional capabilities line +// " [supports_attachments]". The context window is +// written on every model so compaction knows the budget; attachments is +// only written when explicitly answered. +func parseCapabilities(raw string) (int64, *bool, error) { + fields := strings.Fields(raw) + contextW, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil || contextW <= 0 { + return 0, nil, fmt.Errorf("context window must be a positive number of tokens (e.g. 32768)") + } + var attach *bool + if len(fields) > 1 { + switch strings.ToLower(fields[1]) { + case "y", "yes", "true": + value := true + attach = &value + case "n", "no", "false": + value := false + attach = &value + default: + return 0, nil, fmt.Errorf("supports_attachments must be y or n") + } + } + return contextW, attach, nil +} + // providerExists reports whether a provider with this id is already // configured, so the wizard never silently clobbers an existing entry. func (m *ProviderWizard) providerExists(id string) bool { @@ -297,7 +366,7 @@ func (m *ProviderWizard) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } lines = append(lines, m.stepLines()...) lines = append(lines, - textStyle.Render(fmt.Sprintf("Step %d of 4 · written to %s", m.step+1, config.GlobalConfigData())), + textStyle.Render(fmt.Sprintf("Step %d of %d · written to %s", m.step+1, providerWizardStepCount, config.GlobalConfigData())), "", helpStyle.Render(m.help.View(m)), ) @@ -325,13 +394,21 @@ func (m *ProviderWizard) stepLines() []string { if m.baseURL != "" { lines = append(lines, t.Dialog.SecondaryText.Render("url: "+m.baseURL)) } - if m.step == providerWizardStepModels { + if m.step == providerWizardStepModels || m.step == providerWizardStepCaps { if len(m.models) == 0 { lines = append(lines, t.Dialog.SecondaryText.Render("models: (none yet)")) } else { lines = append(lines, t.Dialog.SecondaryText.Render("models: "+strings.Join(m.models, ", "))) } } + if m.step == providerWizardStepCaps { + if m.contextW > 0 { + lines = append(lines, t.Dialog.SecondaryText.Render(fmt.Sprintf("context: %d", m.contextW))) + } + if m.attach != nil { + lines = append(lines, t.Dialog.SecondaryText.Render(fmt.Sprintf("attach: %t", *m.attach))) + } + } return lines } @@ -352,6 +429,8 @@ func (m *ProviderWizard) headerView() string { title = textStyle.Render("Enter the ") + accentStyle.Render("API Key") + textStyle.Render(" (optional).") case providerWizardStepModels: title = textStyle.Render("Add ") + accentStyle.Render("Model IDs") + textStyle.Render(".") + case providerWizardStepCaps: + title = textStyle.Render("Model ") + accentStyle.Render("Capabilities") + textStyle.Render(" (optional).") } headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() @@ -379,9 +458,11 @@ func (m *ProviderWizard) ShortHelp() []key.Binding { case providerWizardStepModels: submit := key.NewBinding( key.WithKeys("enter"), - key.WithHelp("enter", "add model / finish"), + key.WithHelp("enter", "add model / continue"), ) bindings = append(bindings, submit, m.keyMap.Remove) + case providerWizardStepCaps: + bindings = append(bindings, m.keyMap.Submit) default: bindings = append(bindings, m.keyMap.Submit) } diff --git a/internal/ui/dialog/provider_wizard_test.go b/internal/ui/dialog/provider_wizard_test.go index 420db34..42637e6 100644 --- a/internal/ui/dialog/provider_wizard_test.go +++ b/internal/ui/dialog/provider_wizard_test.go @@ -97,7 +97,7 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { assert.Equal(t, providerWizardStepModels, w.step) // Step 4: enter with no models errors; typing + enter accumulates; - // enter on empty input emits the save action. + // enter on empty input moves to the capabilities screen. w.modelInput.SetValue("") require.Nil(t, w.advance()) assert.NotEmpty(t, w.errText) @@ -110,6 +110,20 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { require.Nil(t, w.advance()) assert.Equal(t, []string{"qwen3-32b", "llama-3.1-70b"}, w.models) + // HandleMsg routes the empty-input enter to the capabilities screen. + w.errText = "" + require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) + assert.Equal(t, providerWizardStepCaps, w.step) + + // Capabilities input is validated; invalid values stay on the step. + w.capInput.SetValue("abc") + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepCaps, w.step) + assert.NotEmpty(t, w.errText) + + // Empty capabilities input skips both fields. + w.capInput.SetValue("") + w.errText = "" action := w.advance() save, ok := action.(ActionSetupProvider) require.True(t, ok, "expected ActionSetupProvider, got %T", action) @@ -117,6 +131,42 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { assert.Equal(t, "http://192.168.1.10:8000/v1", save.BaseURL) assert.Empty(t, save.APIKey) assert.Equal(t, []string{"qwen3-32b", "llama-3.1-70b"}, save.Models) + assert.Zero(t, save.ContextW) + assert.Nil(t, save.Attachments) +} + +func TestProviderWizardCapabilities(t *testing.T) { + t.Parallel() + + w := newTestWizard(t) + w.step = providerWizardStepCaps + w.models = []string{"qwen3-32b"} + + w.capInput.SetValue("32768 y") + action := w.advance() + save, ok := action.(ActionSetupProvider) + require.True(t, ok, "expected ActionSetupProvider, got %T", action) + assert.Equal(t, int64(32768), save.ContextW) + require.NotNil(t, save.Attachments) + assert.True(t, *save.Attachments) + + w.capInput.SetValue("131072 n") + action = w.advance() + save, ok = action.(ActionSetupProvider) + require.True(t, ok) + assert.Equal(t, int64(131072), save.ContextW) + require.NotNil(t, save.Attachments) + assert.False(t, *save.Attachments) + + w.capInput.SetValue("32768 maybe") + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepCaps, w.step) + assert.NotEmpty(t, w.errText) + + w.capInput.SetValue("0") + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepCaps, w.step) + assert.NotEmpty(t, w.errText) } func TestProviderWizardKeys(t *testing.T) { @@ -139,16 +189,20 @@ func TestProviderWizardKeys(t *testing.T) { require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) assert.NotEmpty(t, w.errText) - // Re-adding a model and pressing enter on empty input finishes. + // Re-adding a model and pressing enter on empty input moves to the + // capabilities screen; enter there (empty) finishes the wizard. w.modelInput.SetValue("qwen3-32b") require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) + require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) + assert.Equal(t, providerWizardStepCaps, w.step) _, ok := w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter}).(ActionSetupProvider) require.True(t, ok, "expected ActionSetupProvider on finish") - // Backspace on the empty model input steps back to the key step. - w.modelInput.SetValue("") + // Backspace on the empty capabilities input steps back to the + // models step. + w.capInput.SetValue("") require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyBackspace})) - assert.Equal(t, providerWizardStepKey, w.step) + assert.Equal(t, providerWizardStepModels, w.step) // Escape closes the wizard. assert.Equal(t, ActionClose{}, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEscape})) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e4bda60..3a97627 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -4878,7 +4878,14 @@ func (m *UI) setupProvider(msg dialog.ActionSetupProvider) tea.Cmd { models := make([]map[string]any, 0, len(msg.Models)) for _, id := range msg.Models { - models = append(models, map[string]any{"id": id, "name": id}) + model := map[string]any{"id": id, "name": id} + if msg.ContextW > 0 { + model["context_window"] = msg.ContextW + } + if msg.Attachments != nil { + model["supports_attachments"] = *msg.Attachments + } + models = append(models, model) } type configField struct { From 2efc48c2e935855ebf9a635028b31fe67d7ff34d Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 10:18:32 -0800 Subject: [PATCH 3/8] fix: render the provider wizard as a framed centered dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unframed onboarding rendering blended into the provider picker behind it, making the form hard to read. The wizard is now always a bordered, centered dialog regardless of where it is opened from. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- docs/architecture/PROVIDER_ONBOARDING.md | 3 +++ internal/ui/dialog/provider_wizard.go | 24 ++++++++-------------- internal/ui/dialog/provider_wizard_test.go | 2 +- internal/ui/model/ui.go | 2 +- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md index b80e569..a714658 100644 --- a/docs/architecture/PROVIDER_ONBOARDING.md +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -67,6 +67,9 @@ answered). Leaving it empty writes neither field — see when the window is zero, so an unset window silently disables context compaction: +- The wizard always renders as a framed, centered dialog — even during + onboarding — so it stays visually distinct from the provider picker + behind it. - While the wizard is the front dialog, `esc` closes *it* and returns to the provider list (it remains swallowed only for the provider picker itself, so onboarding stays inescapable). This also applies to diff --git a/internal/ui/dialog/provider_wizard.go b/internal/ui/dialog/provider_wizard.go index ed69e45..5b2e788 100644 --- a/internal/ui/dialog/provider_wizard.go +++ b/internal/ui/dialog/provider_wizard.go @@ -57,10 +57,9 @@ type ActionSetupProvider struct { // an optional API key, and a list of model ids, then hands everything to // the UI model to write to the global config as a custom provider. type ProviderWizard struct { - com *common.Common - isOnboarding bool - width int - step providerWizardStep + com *common.Common + width int + step providerWizardStep providerID string baseURL string @@ -87,13 +86,12 @@ type ProviderWizard struct { var _ Dialog = (*ProviderWizard)(nil) -// NewProviderWizard creates a new provider setup wizard dialog. When -// isOnboarding is true the wizard renders in the bottom-left onboarding -// style (unframed) instead of as a centered dialog. -func NewProviderWizard(com *common.Common, isOnboarding bool) *ProviderWizard { +// NewProviderWizard creates a new provider setup wizard dialog. It always +// renders as a framed, centered dialog so it stands apart from whatever +// dialog (e.g. the onboarding provider picker) sits behind it. +func NewProviderWizard(com *common.Common) *ProviderWizard { m := &ProviderWizard{} m.com = com - m.isOnboarding = isOnboarding m.width = 60 innerWidth := m.width - com.Styles.Dialog.View.GetHorizontalFrameSize() - 2 // (1) cursor padding @@ -373,13 +371,7 @@ func (m *ProviderWizard) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { view := dialogStyle.Render(strings.Join(lines, "\n")) cur := m.Cursor() - if m.isOnboarding { - view = strings.Join(lines, "\n") - cur = adjustOnboardingInputCursor(t, cur) - DrawOnboardingCursor(scr, area, view, cur) - } else { - DrawCenterCursor(scr, area, view, cur) - } + DrawCenterCursor(scr, area, view, cur) return cur } diff --git a/internal/ui/dialog/provider_wizard_test.go b/internal/ui/dialog/provider_wizard_test.go index 42637e6..995a852 100644 --- a/internal/ui/dialog/provider_wizard_test.go +++ b/internal/ui/dialog/provider_wizard_test.go @@ -13,7 +13,7 @@ import ( func newTestWizard(t *testing.T) *ProviderWizard { t.Helper() s := styles.CharmtonePantera() - return NewProviderWizard(&common.Common{Styles: &s}, false) + return NewProviderWizard(&common.Common{Styles: &s}) } func TestNormalizeBaseURL(t *testing.T) { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 3a97627..fd16edd 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -4862,7 +4862,7 @@ func (m *UI) openProviderWizardDialog() tea.Cmd { return nil } - m.dialog.OpenDialog(dialog.NewProviderWizard(m.com, m.state == uiOnboarding)) + m.dialog.OpenDialog(dialog.NewProviderWizard(m.com)) return nil } From c4444a200da6fa2d0dafab6f0b492e19e848fa58 Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 10:36:13 -0800 Subject: [PATCH 4/8] feat: give the provider wizard dedicated capability fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capabilities screen accepted a free-form "32768 y" line, which read like it wanted JSON. Split it into a validated numeric context-window step and a yes/no radio selector for attachment support, so every answer is guaranteed valid. The dialog is now larger (70 columns) and shows a fixed-size summary of all fields on every step, so it never resizes mid-flow. Added a config round-trip test proving both fields survive the load pipeline. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- docs/architecture/PROVIDER_ONBOARDING.md | 27 ++- internal/ui/dialog/provider_wizard.go | 227 +++++++++++------- internal/ui/dialog/provider_wizard_test.go | 99 +++++--- .../custom_provider_capabilities_test.go | 47 ++++ 4 files changed, 272 insertions(+), 128 deletions(-) create mode 100644 pkg/config/custom_provider_capabilities_test.go diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md index a714658..a6ab192 100644 --- a/docs/architecture/PROVIDER_ONBOARDING.md +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -57,15 +57,24 @@ first authenticate a cloud provider, or hand-edit The onboarding provider list now starts with a synthetic *Set up a custom provider* entry. Selecting it emits `ActionOpenDialog{DialogID: ProviderWizardID}`, which opens the -existing 5-step wizard (name → base URL → API key → model IDs → -capabilities) directly on top of the onboarding screen. The -capabilities step is optional: it collects `context_window` (written to -every model so compaction knows the budget) and -`supports_attachments` (written to every model only when explicitly -answered). Leaving it empty writes neither field — see -`shouldSummarize` in `pkg/agent/agent.go`, which skips summarization -when the window is zero, so an unset window silently disables context -compaction: +existing 6-step wizard (name → base URL → API key → model IDs → +context window → attachments) directly on top of the onboarding screen: + +- The dialog is a framed, centered box with fixed dimensions: the + summary panel lists every field (provider, url, api key, models, + context, attachments) on every step, so collected values appear as + the user progresses and the box never resizes. +- The context-window step takes a positive token count and is skippable + (empty input). +- The attachments step is a yes/no radio selector (arrows move, enter + confirms), so the stored value is always a valid boolean. +- The same fields are editable later via *Manage Custom Providers* + (`context_window` and `supports_attachments` rows on each model). + `pkg/config/custom_provider_capabilities_test.go` pins that both + fields survive the config load pipeline. +- An unset `context_window` (0) silently disables context compaction — + `shouldSummarize` in `pkg/agent/agent.go` skips summarization when + the window is zero — so the value is worth filling in. - The wizard always renders as a framed, centered dialog — even during onboarding — so it stays visually distinct from the provider picker diff --git a/internal/ui/dialog/provider_wizard.go b/internal/ui/dialog/provider_wizard.go index 5b2e788..1f8f0b0 100644 --- a/internal/ui/dialog/provider_wizard.go +++ b/internal/ui/dialog/provider_wizard.go @@ -1,6 +1,7 @@ package dialog import ( + "cmp" "fmt" "net/url" "regexp" @@ -28,12 +29,18 @@ const ( providerWizardStepURL providerWizardStepKey providerWizardStepModels - providerWizardStepCaps + providerWizardStepContext + providerWizardStepAttach ) // providerWizardStepCount is the total number of wizard screens, used in // the "Step X of N" footer. -const providerWizardStepCount = int(providerWizardStepCaps) + 1 +const providerWizardStepCount = int(providerWizardStepAttach) + 1 + +// providerWizardHeight is the fixed content height of the dialog. +// Padding every step to this height keeps the dialog from resizing as +// the collected values appear. +const providerWizardHeight = 10 // providerIDPattern is the accepted character set for a provider id. Dots // are deliberately excluded because config writes use dotted key paths @@ -52,6 +59,14 @@ type ActionSetupProvider struct { Attachments *bool } +// boolOption identifies one choice on the boolean capabilities screen. +type boolOption int + +const ( + boolOptionYes boolOption = iota + boolOptionNo +) + // ProviderWizard is a small linear wizard that collects an // OpenAI-compatible endpoint (e.g. llama.cpp, vLLM, Ollama, LM Studio), // an optional API key, and a list of model ids, then hands everything to @@ -67,19 +82,22 @@ type ProviderWizard struct { models []string contextW int64 attach *bool + attachSel boolOption errText string - nameInput textinput.Model - urlInput textinput.Model - keyInput textinput.Model - modelInput textinput.Model - capInput textinput.Model + nameInput textinput.Model + urlInput textinput.Model + keyInput textinput.Model + modelInput textinput.Model + contextWInput textinput.Model help help.Model keyMap struct { Submit key.Binding Remove key.Binding + Prev key.Binding + Next key.Binding Close key.Binding } } @@ -92,7 +110,7 @@ var _ Dialog = (*ProviderWizard)(nil) func NewProviderWizard(com *common.Common) *ProviderWizard { m := &ProviderWizard{} m.com = com - m.width = 60 + m.width = 70 innerWidth := m.width - com.Styles.Dialog.View.GetHorizontalFrameSize() - 2 // (1) cursor padding @@ -100,7 +118,7 @@ func NewProviderWizard(com *common.Common) *ProviderWizard { m.urlInput = newWizardInput(com, innerWidth, "http://localhost:11434/v1") m.keyInput = newWizardInput(com, innerWidth, "leave empty if the server doesn't need one") m.modelInput = newWizardInput(com, innerWidth, "e.g. qwen3-32b") - m.capInput = newWizardInput(com, innerWidth, "e.g. 32768 y (leave empty to skip)") + m.contextWInput = newWizardInput(com, innerWidth, "e.g. 32768 (leave empty to skip)") m.nameInput.Focus() m.help = help.New() @@ -114,6 +132,14 @@ func NewProviderWizard(com *common.Common) *ProviderWizard { key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "remove last model"), ) + m.keyMap.Prev = key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←", "previous"), + ) + m.keyMap.Next = key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→", "next"), + ) m.keyMap.Close = CloseKey return m @@ -147,22 +173,39 @@ func (m *ProviderWizard) HandleMsg(msg tea.Msg) Action { } return nil case key.Matches(msg, m.keyMap.Submit): - if m.step == providerWizardStepCaps { + if m.step == providerWizardStepAttach { return m.advance() } // On the model-ID screen an empty input means "done adding - // models": move to the capabilities screen. With no models + // models": move to the context-window screen. With no models // collected yet, fall through to advance() so the required // list error surfaces instead of silently skipping. if m.step == providerWizardStepModels && strings.TrimSpace(m.modelInput.Value()) == "" && len(m.models) > 0 { - m.step = providerWizardStepCaps + m.step = providerWizardStepContext m.errText = "" m.focusStep() return nil } return m.advance() + case m.step == providerWizardStepAttach && + (key.Matches(msg, m.keyMap.Prev) || key.Matches(msg, m.keyMap.Next)): + if key.Matches(msg, m.keyMap.Prev) { + m.attachSel = boolOptionYes + } else { + m.attachSel = boolOptionNo + } + return nil default: // Backspace on an empty input steps back a screen. + if m.step == providerWizardStepAttach { + if msg.String() == "backspace" && m.step > providerWizardStepName { + m.step-- + m.errText = "" + m.focusStep() + return nil + } + return nil + } input := m.currentInput() if msg.String() == "backspace" && input.Value() == "" && m.step > providerWizardStepName { m.step-- @@ -178,6 +221,9 @@ func (m *ProviderWizard) HandleMsg(msg tea.Msg) Action { } } case tea.PasteMsg: + if m.step == providerWizardStepAttach { + return nil + } input := m.currentInput() var cmd tea.Cmd *input, cmd = input.Update(msg) @@ -198,8 +244,10 @@ func (m *ProviderWizard) currentInput() *textinput.Model { return &m.keyInput case providerWizardStepModels: return &m.modelInput + case providerWizardStepContext: + return &m.contextWInput default: - return &m.capInput + return nil } } @@ -208,8 +256,10 @@ func (m *ProviderWizard) focusStep() { m.urlInput.Blur() m.keyInput.Blur() m.modelInput.Blur() - m.capInput.Blur() - m.currentInput().Focus() + m.contextWInput.Blur() + if input := m.currentInput(); input != nil { + input.Focus() + } } // advance validates the current step and moves to the next, or emits the @@ -259,55 +309,33 @@ func (m *ProviderWizard) advance() Action { m.errText = "Add at least one model id (press enter after typing it)." return nil } - m.step = providerWizardStepCaps + m.step = providerWizardStepContext m.focusStep() - case providerWizardStepCaps: - action := ActionSetupProvider{ - ProviderID: m.providerID, - BaseURL: m.baseURL, - APIKey: m.apiKey, - Models: slices.Clone(m.models), - } - if raw := strings.TrimSpace(m.capInput.Value()); raw != "" { - contextW, attach, err := parseCapabilities(raw) - if err != nil { - m.errText = err.Error() + case providerWizardStepContext: + raw := strings.TrimSpace(m.contextWInput.Value()) + if raw != "" { + contextW, err := strconv.ParseInt(raw, 10, 64) + if err != nil || contextW <= 0 { + m.errText = "Context window must be a positive number of tokens (e.g. 32768)." return nil } - action.ContextW = contextW - action.Attachments = attach m.contextW = contextW - m.attach = attach } - return action - } - return nil -} - -// parseCapabilities parses the optional capabilities line -// " [supports_attachments]". The context window is -// written on every model so compaction knows the budget; attachments is -// only written when explicitly answered. -func parseCapabilities(raw string) (int64, *bool, error) { - fields := strings.Fields(raw) - contextW, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil || contextW <= 0 { - return 0, nil, fmt.Errorf("context window must be a positive number of tokens (e.g. 32768)") - } - var attach *bool - if len(fields) > 1 { - switch strings.ToLower(fields[1]) { - case "y", "yes", "true": - value := true - attach = &value - case "n", "no", "false": - value := false - attach = &value - default: - return 0, nil, fmt.Errorf("supports_attachments must be y or n") + m.step = providerWizardStepAttach + m.focusStep() + case providerWizardStepAttach: + value := m.attachSel == boolOptionYes + m.attach = &value + return ActionSetupProvider{ + ProviderID: m.providerID, + BaseURL: m.baseURL, + APIKey: m.apiKey, + Models: slices.Clone(m.models), + ContextW: m.contextW, + Attachments: m.attach, } } - return contextW, attach, nil + return nil } // providerExists reports whether a provider with this id is already @@ -368,6 +396,10 @@ func (m *ProviderWizard) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { "", helpStyle.Render(m.help.View(m)), ) + // Pad short steps so the dialog never changes size mid-flow. + for len(lines) < providerWizardHeight { + lines = append(lines, "") + } view := dialogStyle.Render(strings.Join(lines, "\n")) cur := m.Cursor() @@ -375,33 +407,32 @@ func (m *ProviderWizard) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { return cur } -// stepLines renders the running summary of everything collected so far so -// the user always sees the full picture, including the model list. +// stepLines renders the running summary of everything collected so far +// so the user always sees the full picture, including the model list. +// Fields whose value is not known yet hold a placeholder so the dialog +// keeps the same height on every step. func (m *ProviderWizard) stepLines() []string { t := m.com.Styles - var lines []string - if m.providerID != "" { - lines = append(lines, t.Dialog.SecondaryText.Render("provider: "+m.providerID)) + models := "(pending)" + if len(m.models) > 0 { + models = strings.Join(m.models, ", ") } - if m.baseURL != "" { - lines = append(lines, t.Dialog.SecondaryText.Render("url: "+m.baseURL)) + contextW := "(skipped)" + if m.contextW > 0 { + contextW = strconv.FormatInt(m.contextW, 10) } - if m.step == providerWizardStepModels || m.step == providerWizardStepCaps { - if len(m.models) == 0 { - lines = append(lines, t.Dialog.SecondaryText.Render("models: (none yet)")) - } else { - lines = append(lines, t.Dialog.SecondaryText.Render("models: "+strings.Join(m.models, ", "))) - } + attach := "(pending)" + if m.attach != nil { + attach = fmt.Sprintf("%t", *m.attach) } - if m.step == providerWizardStepCaps { - if m.contextW > 0 { - lines = append(lines, t.Dialog.SecondaryText.Render(fmt.Sprintf("context: %d", m.contextW))) - } - if m.attach != nil { - lines = append(lines, t.Dialog.SecondaryText.Render(fmt.Sprintf("attach: %t", *m.attach))) - } + return []string{ + t.Dialog.SecondaryText.Render("provider: " + cmp.Or(m.providerID, "(pending)")), + t.Dialog.SecondaryText.Render("url: " + cmp.Or(m.baseURL, "(pending)")), + t.Dialog.SecondaryText.Render("api key: " + cmp.Or(m.apiKey, "(none)")), + t.Dialog.SecondaryText.Render("models: " + models), + t.Dialog.SecondaryText.Render("context: " + contextW), + t.Dialog.SecondaryText.Render("attachments: " + attach), } - return lines } func (m *ProviderWizard) headerView() string { @@ -421,21 +452,49 @@ func (m *ProviderWizard) headerView() string { title = textStyle.Render("Enter the ") + accentStyle.Render("API Key") + textStyle.Render(" (optional).") case providerWizardStepModels: title = textStyle.Render("Add ") + accentStyle.Render("Model IDs") + textStyle.Render(".") - case providerWizardStepCaps: - title = textStyle.Render("Model ") + accentStyle.Render("Capabilities") + textStyle.Render(" (optional).") + case providerWizardStepContext: + title = textStyle.Render("Set the ") + accentStyle.Render("Context Window") + textStyle.Render(" (optional).") + case providerWizardStepAttach: + title = textStyle.Render("Does it support ") + accentStyle.Render("Attachments") + textStyle.Render("?") } headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() return common.DialogTitle(t, titleStyle.Render(title), m.width-headerOffset, t.Dialog.TitleGradFromColor, t.Dialog.TitleGradToColor) } +// inputView renders the current input, or the yes/no selector on the +// boolean capabilities screen. func (m *ProviderWizard) inputView() string { + if m.step == providerWizardStepAttach { + return m.attachView() + } return m.currentInput().View() } -// Cursor returns the cursor position relative to the dialog. +// attachView renders the yes/no selector with radio markers so the +// answer is always a valid boolean. +func (m *ProviderWizard) attachView() string { + t := m.com.Styles + yesRadio, noRadio := t.Radio.Off, t.Radio.Off + if m.attachSel == boolOptionYes { + yesRadio = t.Radio.On + } else { + noRadio = t.Radio.On + } + return fmt.Sprintf("%s %s %s %s", + yesRadio.Render(), t.Radio.Label.Render("Yes"), + noRadio.Render(), t.Radio.Label.Render("No"), + ) +} + +// Cursor returns the cursor position relative to the dialog. The +// boolean screen has no text input, so it shows no cursor. func (m *ProviderWizard) Cursor() *tea.Cursor { - return InputCursor(m.com.Styles, m.currentInput().Cursor()) + input := m.currentInput() + if input == nil { + return nil + } + return InputCursor(m.com.Styles, input.Cursor()) } // FullHelp returns the full help view. @@ -453,8 +512,8 @@ func (m *ProviderWizard) ShortHelp() []key.Binding { key.WithHelp("enter", "add model / continue"), ) bindings = append(bindings, submit, m.keyMap.Remove) - case providerWizardStepCaps: - bindings = append(bindings, m.keyMap.Submit) + case providerWizardStepAttach: + bindings = append(bindings, m.keyMap.Prev, m.keyMap.Next, m.keyMap.Submit) default: bindings = append(bindings, m.keyMap.Submit) } diff --git a/internal/ui/dialog/provider_wizard_test.go b/internal/ui/dialog/provider_wizard_test.go index 995a852..921553b 100644 --- a/internal/ui/dialog/provider_wizard_test.go +++ b/internal/ui/dialog/provider_wizard_test.go @@ -97,7 +97,7 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { assert.Equal(t, providerWizardStepModels, w.step) // Step 4: enter with no models errors; typing + enter accumulates; - // enter on empty input moves to the capabilities screen. + // enter on empty input moves to the context-window screen. w.modelInput.SetValue("") require.Nil(t, w.advance()) assert.NotEmpty(t, w.errText) @@ -110,20 +110,24 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { require.Nil(t, w.advance()) assert.Equal(t, []string{"qwen3-32b", "llama-3.1-70b"}, w.models) - // HandleMsg routes the empty-input enter to the capabilities screen. + // HandleMsg routes the empty-input enter to the context-window screen. w.errText = "" require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) - assert.Equal(t, providerWizardStepCaps, w.step) + assert.Equal(t, providerWizardStepContext, w.step) - // Capabilities input is validated; invalid values stay on the step. - w.capInput.SetValue("abc") + // Invalid context windows stay on the step. + w.contextWInput.SetValue("abc") require.Nil(t, w.advance()) - assert.Equal(t, providerWizardStepCaps, w.step) + assert.Equal(t, providerWizardStepContext, w.step) assert.NotEmpty(t, w.errText) - // Empty capabilities input skips both fields. - w.capInput.SetValue("") + // Empty context window skips the value and moves to attachments. + w.contextWInput.SetValue("") w.errText = "" + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepAttach, w.step) + + // Enter on the boolean screen finishes with an explicit answer. action := w.advance() save, ok := action.(ActionSetupProvider) require.True(t, ok, "expected ActionSetupProvider, got %T", action) @@ -132,41 +136,65 @@ func TestProviderWizardAdvanceFlow(t *testing.T) { assert.Empty(t, save.APIKey) assert.Equal(t, []string{"qwen3-32b", "llama-3.1-70b"}, save.Models) assert.Zero(t, save.ContextW) - assert.Nil(t, save.Attachments) + require.NotNil(t, save.Attachments) + assert.True(t, *save.Attachments) } func TestProviderWizardCapabilities(t *testing.T) { t.Parallel() w := newTestWizard(t) - w.step = providerWizardStepCaps + w.step = providerWizardStepContext w.models = []string{"qwen3-32b"} - w.capInput.SetValue("32768 y") - action := w.advance() - save, ok := action.(ActionSetupProvider) - require.True(t, ok, "expected ActionSetupProvider, got %T", action) - assert.Equal(t, int64(32768), save.ContextW) - require.NotNil(t, save.Attachments) - assert.True(t, *save.Attachments) - - w.capInput.SetValue("131072 n") - action = w.advance() - save, ok = action.(ActionSetupProvider) - require.True(t, ok) - assert.Equal(t, int64(131072), save.ContextW) - require.NotNil(t, save.Attachments) - assert.False(t, *save.Attachments) + w.contextWInput.SetValue("32768") + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepAttach, w.step) + assert.Equal(t, int64(32768), w.contextW) - w.capInput.SetValue("32768 maybe") + // Invalid numbers stay on the step. + w.step = providerWizardStepContext + w.contextWInput.SetValue("32768 maybe") require.Nil(t, w.advance()) - assert.Equal(t, providerWizardStepCaps, w.step) + assert.Equal(t, providerWizardStepContext, w.step) assert.NotEmpty(t, w.errText) - w.capInput.SetValue("0") + w.contextWInput.SetValue("0") require.Nil(t, w.advance()) - assert.Equal(t, providerWizardStepCaps, w.step) + assert.Equal(t, providerWizardStepContext, w.step) assert.NotEmpty(t, w.errText) + + // Empty input skips the value entirely. + w.errText = "" + w.contextWInput.SetValue("") + require.Nil(t, w.advance()) + assert.Equal(t, providerWizardStepAttach, w.step) + assert.Zero(t, w.attach) +} + +func TestProviderWizardAttachmentToggle(t *testing.T) { + t.Parallel() + + w := newTestWizard(t) + w.step = providerWizardStepAttach + w.models = []string{"qwen3-32b"} + + // The selector defaults to yes; arrows set an explicit boolean. + require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyRight})) + assert.Equal(t, boolOptionNo, w.attachSel) + require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyLeft})) + assert.Equal(t, boolOptionYes, w.attachSel) + + action := w.advance() + save, ok := action.(ActionSetupProvider) + require.True(t, ok, "expected ActionSetupProvider, got %T", action) + require.NotNil(t, save.Attachments) + assert.True(t, *save.Attachments) + + // Free-form typing on the boolean screen is ignored entirely. + w.HandleMsg(tea.KeyPressMsg{Code: 'x', Text: "x"}) + assert.Equal(t, boolOptionYes, w.attachSel) + assert.Equal(t, providerWizardStepAttach, w.step) } func TestProviderWizardKeys(t *testing.T) { @@ -190,19 +218,20 @@ func TestProviderWizardKeys(t *testing.T) { assert.NotEmpty(t, w.errText) // Re-adding a model and pressing enter on empty input moves to the - // capabilities screen; enter there (empty) finishes the wizard. + // capabilities screens; enter on the context-window screen (empty) + // skips it, and enter on the boolean screen finishes the wizard. w.modelInput.SetValue("qwen3-32b") require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) - assert.Equal(t, providerWizardStepCaps, w.step) + assert.Equal(t, providerWizardStepContext, w.step) + require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter})) + assert.Equal(t, providerWizardStepAttach, w.step) _, ok := w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEnter}).(ActionSetupProvider) require.True(t, ok, "expected ActionSetupProvider on finish") - // Backspace on the empty capabilities input steps back to the - // models step. - w.capInput.SetValue("") + // Backspace on the boolean screen steps back to the context step. require.Nil(t, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyBackspace})) - assert.Equal(t, providerWizardStepModels, w.step) + assert.Equal(t, providerWizardStepContext, w.step) // Escape closes the wizard. assert.Equal(t, ActionClose{}, w.HandleMsg(tea.KeyPressMsg{Code: tea.KeyEscape})) diff --git a/pkg/config/custom_provider_capabilities_test.go b/pkg/config/custom_provider_capabilities_test.go new file mode 100644 index 0000000..ef44a75 --- /dev/null +++ b/pkg/config/custom_provider_capabilities_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCustomProviderCapabilitiesRoundTrip pins that capabilities fields +// written by the provider wizard survive the full load pipeline, so +// compaction and attachment handling see the user's choices. +func TestCustomProviderCapabilitiesRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("PHOSPHOR_GLOBAL_CONFIG", dir) + t.Setenv("PHOSPHOR_GLOBAL_DATA", dir) + + configJSON := `{ + "providers": { + "ollama": { + "name": "ollama", + "type": "openai-compat", + "base_url": "http://localhost:11434/v1", + "models": [ + { + "id": "qwen3-32b", + "name": "qwen3-32b", + "context_window": 32768, + "supports_attachments": true + } + ] + } + } + }` + require.NoError(t, os.WriteFile(filepath.Join(dir, "phosphor.json"), []byte(configJSON), 0o600)) + + store, err := Load(dir, dir, false) + require.NoError(t, err) + + cfg := store.Config() + provider, ok := cfg.Providers.Get("ollama") + require.True(t, ok, "custom provider must be present") + require.Len(t, provider.Models, 1) + require.Equal(t, int64(32768), provider.Models[0].ContextWindow) + require.True(t, provider.Models[0].SupportsImages) +} From cab6c294a0b1515dd3a24812ab320e9df07f5dbf Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 10:56:39 -0800 Subject: [PATCH 5/8] fix: drop the redundant SetupAgents call after wizard provider setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config store's auto-reload already runs SetupAgents whenever the first provider makes the config usable, so the direct call added on the wizard path was always a no-op. Verified the image-attachment check (currentModelSupportsImages) resolves correctly for wizard-created providers: the config it writes is byte-for-byte the shape the provider manager and hand-written configs use. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- docs/architecture/PROVIDER_ONBOARDING.md | 6 ++++++ internal/ui/model/ui.go | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md index a6ab192..a300c5d 100644 --- a/docs/architecture/PROVIDER_ONBOARDING.md +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -88,6 +88,12 @@ context window → attachments) directly on top of the onboarding screen: (`setupProvider` → `SetConfigField(ScopeGlobal, …)`), then closes the wizard and refreshes the provider list so the new provider's models appear immediately. +- The config store's `autoReload` after the write re-runs + `SetupAgents()` (see `reloadFromDiskLocked` in `pkg/config/store.go`), + so `currentModelSupportsImages()` — which reads + `cfg.Agents[AgentSystem]` — sees a valid agent as soon as the first + provider lands. The wizard used to call `SetupAgents()` directly; that + call was redundant and was removed. - The user then picks one of those models; `handleSelectModel` sees the provider is configured, skips authentication, sets large/small model preferences (custom providers fall back to the large model as the diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index fd16edd..4dbbd50 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2609,7 +2609,9 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd { if isOnboarding { m.setState(uiLanding, uiFocusEditor) - m.com.Config().SetupAgents() + // SetupAgents is run by the config store's auto-reload once the + // first provider makes the config usable, so it is not needed + // here. if err := m.com.Workspace.InitSystemAgent(context.TODO()); err != nil { cmds = append(cmds, util.ReportError(err)) } From 8800f2ef4fa8589584ea7f6f1ef2e16c431858da Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 11:24:55 -0800 Subject: [PATCH 6/8] feat: auto-select the wizard's first model to finish onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishing the custom provider wizard during onboarding returned to the provider list and silently left no large model selected, so image paste and other model capability checks stayed disabled. The wizard now selects its first configured model exactly like the API-key dialog does, recording models.large in the workspace config and initializing the agent before landing on the main screen. ஃ Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- docs/architecture/PROVIDER_ONBOARDING.md | 18 +++++++------ internal/ui/model/ui.go | 33 ++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/architecture/PROVIDER_ONBOARDING.md b/docs/architecture/PROVIDER_ONBOARDING.md index a300c5d..58d70cf 100644 --- a/docs/architecture/PROVIDER_ONBOARDING.md +++ b/docs/architecture/PROVIDER_ONBOARDING.md @@ -38,8 +38,9 @@ Input is captured at two layers: Selecting a provider/model chains into the credential dialogs (`openAuthenticationDialog` → `NewOAuthHyper` / `NewOAuthCopilot` / `NewAPIKeyInput`). Completing the flow calls `handleSelectModel`, which -flips the state to `uiLanding`, runs `SetupAgents()`, and initializes -the system agent. +flips the state to `uiLanding`, and initializes the system agent (the +config store's `autoReload` runs `SetupAgents()` as soon as the first +provider makes the config usable). ## The chicken-and-egg problem @@ -88,12 +89,13 @@ context window → attachments) directly on top of the onboarding screen: (`setupProvider` → `SetConfigField(ScopeGlobal, …)`), then closes the wizard and refreshes the provider list so the new provider's models appear immediately. -- The config store's `autoReload` after the write re-runs - `SetupAgents()` (see `reloadFromDiskLocked` in `pkg/config/store.go`), - so `currentModelSupportsImages()` — which reads - `cfg.Agents[AgentSystem]` — sees a valid agent as soon as the first - provider lands. The wizard used to call `SetupAgents()` directly; that - call was redundant and was removed. +- Finishing the wizard auto-selects the first configured model as the + large model (mirroring the API-key dialog, which selects the model + the user was on). `handleSelectModel` then records `models.large` in + the workspace config, defaults the small model to the same one, + initializes the system agent, and leaves onboarding — so image paste + (`currentModelSupportsImages`, which resolves through the agent's + large model) works immediately after setup. - The user then picks one of those models; `handleSelectModel` sees the provider is configured, skips authentication, sets large/small model preferences (custom providers fall back to the large model as the diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 4dbbd50..d915666 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -4914,8 +4914,37 @@ func (m *UI) setupProvider(msg dialog.ActionSetupProvider) tea.Cmd { m.dialog.CloseDialog(dialog.ProviderWizardID) if m.state == uiOnboarding { - // Refresh the onboarding provider list so the new provider and - // its models appear immediately for the user to pick from. + // The user just added this provider to set up their endpoint; + // pick its first model directly so onboarding completes instead + // of bouncing back to the provider list. This mirrors the + // API-key dialog, which selects the model the user was on. The + // small model defaults to the same one via GetDefaultSmallModel. + // handleSelectModel is called directly rather than emitted as an + // action: by the time an action would round-trip through + // handleDialogMsg, setupProvider has already flipped the state + // to landing and the onboarding branch would no longer run. + if len(msg.Models) > 0 { + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.handleSelectModel(dialog.ActionSelectModel{ + Provider: catwalk.Provider{ + ID: catwalk.InferenceProvider(msg.ProviderID), + Name: msg.ProviderID, + }, + Model: config.SelectedModel{ + Provider: msg.ProviderID, + Model: msg.Models[0], + }, + ModelType: config.SelectedModelTypeLarge, + }); cmd != nil { + return cmd + } + return util.ReportInfo(fmt.Sprintf( + "Provider %q added. %s is ready — Ctrl+L to switch models.", + msg.ProviderID, msg.Models[0], + )) + } + // No models collected (older/invalid flow): refresh the list so + // the provider appears for manual selection. m.dialog.CloseDialog(dialog.ModelsID) if cmd := m.openModelsDialog(); cmd != nil { return cmd From ed829ecda1882ddf2d58fdbb734202bd51e58a5d Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 12:07:29 -0800 Subject: [PATCH 7/8] fix: clear the stored context window when the wizard input is emptied Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- internal/ui/dialog/provider_wizard.go | 4 ++++ internal/ui/dialog/provider_wizard_test.go | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/provider_wizard.go b/internal/ui/dialog/provider_wizard.go index 1f8f0b0..6537c13 100644 --- a/internal/ui/dialog/provider_wizard.go +++ b/internal/ui/dialog/provider_wizard.go @@ -320,6 +320,10 @@ func (m *ProviderWizard) advance() Action { return nil } m.contextW = contextW + } else { + // Navigating back and clearing the field must clear the + // stored value too, otherwise setup keeps the old window. + m.contextW = 0 } m.step = providerWizardStepAttach m.focusStep() diff --git a/internal/ui/dialog/provider_wizard_test.go b/internal/ui/dialog/provider_wizard_test.go index 921553b..5cb1bda 100644 --- a/internal/ui/dialog/provider_wizard_test.go +++ b/internal/ui/dialog/provider_wizard_test.go @@ -164,11 +164,14 @@ func TestProviderWizardCapabilities(t *testing.T) { assert.Equal(t, providerWizardStepContext, w.step) assert.NotEmpty(t, w.errText) - // Empty input skips the value entirely. + // Empty input skips the value entirely, and clears any value + // entered on a previous pass through the step. w.errText = "" + w.contextW = 32768 w.contextWInput.SetValue("") require.Nil(t, w.advance()) assert.Equal(t, providerWizardStepAttach, w.step) + assert.Zero(t, w.contextW) assert.Zero(t, w.attach) } From bba2cd26e5770615ef6fdb18c0d2fe7fecbc9560 Mon Sep 17 00:00:00 2001 From: Tom Maiaroto Date: Sun, 8 Nov 2026 12:14:51 -0800 Subject: [PATCH 8/8] fix: keep the onboarding picker open when the agent is busy Generated with Phosphor Assisted-by: Phosphor:CYBER-FROST-3.8-EXL3-SAGE-3.35bpw --- internal/ui/model/ui.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d915666..a9876a0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -4914,6 +4914,16 @@ func (m *UI) setupProvider(msg dialog.ActionSetupProvider) tea.Cmd { m.dialog.CloseDialog(dialog.ProviderWizardID) if m.state == uiOnboarding { + // handleSelectModel bails out (and closes nothing) when the + // agent is busy — another client can start the shared agent + // during onboarding in client/server mode. Check before closing + // the wizard and picker so the user can retry after the agent + // finishes instead of landing in a dialog-less dead end. The + // OAuth carve-out mirrors handleSelectModel. + if m.isAgentBusy() && !m.dialog.ContainsDialog(dialog.OAuthID) { + m.dialog.CloseDialog(dialog.ProviderWizardID) + return util.ReportWarn("Agent is busy, please wait...") + } // The user just added this provider to set up their endpoint; // pick its first model directly so onboarding completes instead // of bouncing back to the provider list. This mirrors the