Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ completions/phosphor.*sh
# Local planning/scratch artifacts (temporary working docs, never committed)
/_scratch/
.gocache/
/.tmp/
1 change: 1 addition & 0 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
154 changes: 154 additions & 0 deletions docs/architecture/PROVIDER_ONBOARDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# 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`, 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

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 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
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
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.
- 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
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.
39 changes: 38 additions & 1 deletion internal/ui/dialog/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:]...)
}
}

Expand Down
107 changes: 107 additions & 0 deletions internal/ui/dialog/models_onboarding_test.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading