Skip to content

Commit 9df0655

Browse files
committed
Refactor /config setup: gateway-first UI and fix empty-model chat errors
Merge the Keys tab into Gateways so setup is Gateways then Models: select a gateway, paste the API key on the same tab, then pick a model. Remove the old key-first resolve and provider-picker flow from the primary path. Fix deployment router "model is required" when chatting after /config by keeping the cascade router in sync with SetModel and never returning an empty model from SelectModel. Add a stream guard with a clearer error. Update tests, status copy, and bump the eyrie submodule for provider-first credentials and prefix learning.
1 parent 50b4b5a commit 9df0655

24 files changed

Lines changed: 506 additions & 382 deletions

cmd/chat_config_constants.go

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,18 @@ package cmd
33
// Config panel state constants for the /config TUI.
44
//
55
// Fields on chatModel use these values:
6-
// - configTab — main tab (Keys / Gateways / Models)
7-
// - configEntry — input overlay (API key paste, Ollama URL)
6+
// - configTab — main tab (Gateways / Models)
7+
// - configEntry — input overlay (API key paste, Ollama URL, key view)
88
// - configMenu — list overlay (gateway pick after paste)
99
// - configProvider — provider id while an entry overlay is open
1010

1111
// Config tabs (configTab).
1212
const (
13-
configTabKeys = 0
14-
configTabGateways = 1
15-
configTabModels = 2
13+
configTabGateways = 0
14+
configTabModels = 1
1615
)
1716

18-
var configTabLabels = []string{"Keys", "Gateways", "Models"}
17+
var configTabLabels = []string{"Gateways", "Models"}
1918

2019
// Config entry overlays (configEntry).
2120
const (
@@ -31,16 +30,9 @@ const (
3130
configMenuProviders = "providers"
3231
)
3332

34-
// Keys tab row kinds (configKeysRow.kind).
35-
const (
36-
configKeysRowCredential = "credential"
37-
configKeysActionAdd = "add"
38-
configKeysActionOllama = "ollama"
39-
)
40-
4133
// Providers referenced by config UI flows.
4234
const (
4335
configProviderOllama = "ollama"
4436
)
4537

46-
const configDefaultOllamaURL = "http://localhost:11434/v1"
38+
const configDefaultOllamaURL = "http://localhost:11434/v1"

cmd/chat_config_deployment.go

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ func resolveKeyAsync(secret string) tea.Cmd {
4848

4949
func credentialResolveFromRuntime(res runtime.CredentialResolveResult) hawkconfig.CredentialResolveResult {
5050
out := hawkconfig.CredentialResolveResult{
51-
FormatOK: res.FormatOK,
52-
FormatError: res.FormatError,
53-
Providers: make([]hawkconfig.CredentialProviderOption, len(res.Providers)),
51+
FormatOK: res.FormatOK,
52+
FormatError: res.FormatError,
53+
ProbeDisambiguationUsed: res.ProbeDisambiguationUsed,
54+
Providers: make([]hawkconfig.CredentialProviderOption, len(res.Providers)),
5455
}
5556
for i, p := range res.Providers {
5657
out.Providers[i] = hawkconfig.CredentialProviderOption{
@@ -191,10 +192,44 @@ func (m chatModel) configProvidersView() string {
191192
if end < total {
192193
b.WriteString(mutedStyle.Render(fmt.Sprintf(" ··· %d more below ···", total-end)) + "\n")
193194
}
194-
b.WriteString("\n" + mutedStyle.Render(fmt.Sprintf("%d gateways · ★ = suggested · ↑/↓ · enter · esc", total)))
195+
b.WriteString("\n" + mutedStyle.Render(configProviderPickerHelp(total, m.configProviderOptions)))
195196
return b.String()
196197
}
197198

199+
func configProviderPickerHelp(total int, opts []hawkconfig.CredentialProviderOption) string {
200+
if credentialOptionsHaveInference(opts) {
201+
return fmt.Sprintf("%d gateways · ★ = suggested · ↑/↓ · enter · esc", total)
202+
}
203+
return fmt.Sprintf("%d gateways · choose gateway manually · ↑/↓ · enter · esc", total)
204+
}
205+
206+
func credentialOptionsHaveInference(opts []hawkconfig.CredentialProviderOption) bool {
207+
for _, opt := range opts {
208+
if opt.Inferred {
209+
return true
210+
}
211+
}
212+
return false
213+
}
214+
215+
func providerPickerNotice(secret string, opts []hawkconfig.CredentialProviderOption, probeUsed bool) string {
216+
if credentialOptionsHaveInference(opts) {
217+
if probeUsed {
218+
return "Select gateway (★ = matched pattern or verified with provider API)"
219+
}
220+
return "Select gateway (★ = suggested from key shape)"
221+
}
222+
if isGenericOpenAICompatibleKey(secret) {
223+
return "Generic OpenAI-compatible key — choose the gateway it belongs to"
224+
}
225+
return "Select gateway"
226+
}
227+
228+
func isGenericOpenAICompatibleKey(secret string) bool {
229+
secret = strings.TrimSpace(secret)
230+
return strings.HasPrefix(secret, "sk-")
231+
}
232+
198233
func (m chatModel) configProviderLabels() []string {
199234
out := make([]string, len(m.configProviderOptions))
200235
for i, p := range m.configProviderOptions {
@@ -225,10 +260,10 @@ func (m chatModel) handleConfigKeyResolvedMsg(msg configKeyResolvedMsg) (chatMod
225260
m.configProviderOptions = msg.result.Providers
226261
m.configEntry = configEntryNone
227262
m.configMenu = configMenuProviders
228-
m.configTab = configTabKeys
263+
m.configTab = configTabGateways
229264
m.configSel = 0
230265
m.configScroll = 0
231-
m.configNotice = "Select gateway (★ = suggested from key shape)"
266+
m.configNotice = providerPickerNotice(secret, msg.result.Providers, msg.result.ProbeDisambiguationUsed)
232267
m.restoreChatInput()
233268
return m, nil
234269
}
@@ -292,13 +327,14 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg
292327
notice = "Key saved — " + notice + " · retry in Gateways or Models tab"
293328
}
294329
m.configNotice = notice
295-
if strings.TrimSpace(m.configPendingKey) != "" && len(m.configProviderOptions) > 0 {
296-
m.configMenu = configMenuProviders
297-
m.configTab = configTabKeys
298-
m.configSel = 0
299-
} else {
300-
m.configMenu = configMenuNone
301-
m.configTab = configTabKeys
330+
m.configPendingKey = ""
331+
m.configProviderOptions = nil
332+
m.configMenu = configMenuNone
333+
m.configTab = configTabGateways
334+
if pid := strings.TrimSpace(msg.providerID); pid != "" {
335+
if idx := m.configGatewayRowIndex(pid); idx >= 0 {
336+
m.configSel = idx
337+
}
302338
}
303339
return m, nil
304340
}
@@ -318,10 +354,10 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg
318354
next.invalidateConnStatus()
319355
if post := strings.TrimSpace(m.configPostSaveKeysProvider); post != "" {
320356
next.configPostSaveKeysProvider = ""
321-
next.configTab = configTabKeys
357+
next.configTab = configTabGateways
322358
next.configMenu = configMenuNone
323359
next.configEntry = configEntryNone
324-
if idx := next.configKeysCredentialIndex(post); idx >= 0 {
360+
if idx := next.configGatewayRowIndex(post); idx >= 0 {
325361
next.configSel = idx
326362
}
327363
next.configNotice = "Key updated for " + hawkconfig.GatewayDisplayName(post)
@@ -336,7 +372,7 @@ func (m chatModel) handleConfigApplyCredentialsMsg(msg configApplyCredentialsMsg
336372
if msg.providerID == configProviderOllama {
337373
return next.returnToOllamaURLAfterError(fmt.Errorf("no models installed — run: ollama pull llama3.2"))
338374
}
339-
next.configTab = configTabKeys
375+
next.configTab = configTabGateways
340376
next.configNotice = "No models in catalog for " + msg.providerID + " — try another gateway"
341377
return next, cmd
342378
}

cmd/chat_config_deployment_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
8+
)
9+
10+
func TestProviderPickerNotice_GenericOpenAICompatibleKey(t *testing.T) {
11+
opts := []hawkconfig.CredentialProviderOption{
12+
{ProviderID: "openai", DisplayName: "OpenAI"},
13+
{ProviderID: "opencodego", DisplayName: "OpenCode Go"},
14+
}
15+
16+
notice := providerPickerNotice("sk-test-key-that-is-generic", opts, false)
17+
if !strings.Contains(notice, "Generic OpenAI-compatible key") {
18+
t.Fatalf("notice = %q", notice)
19+
}
20+
21+
help := configProviderPickerHelp(len(opts), opts)
22+
if strings.Contains(help, "★ = suggested") {
23+
t.Fatalf("generic key help should not mention suggestions: %q", help)
24+
}
25+
if !strings.Contains(help, "choose gateway manually") {
26+
t.Fatalf("help = %q", help)
27+
}
28+
}
29+
30+
func TestProviderPickerNotice_InferredKey(t *testing.T) {
31+
opts := []hawkconfig.CredentialProviderOption{
32+
{ProviderID: "openrouter", DisplayName: "OpenRouter", Inferred: true},
33+
}
34+
35+
notice := providerPickerNotice("sk-or-test-key", opts, false)
36+
if !strings.Contains(notice, "suggested") {
37+
t.Fatalf("notice = %q", notice)
38+
}
39+
40+
help := configProviderPickerHelp(len(opts), opts)
41+
if !strings.Contains(help, "★ = suggested") {
42+
t.Fatalf("help = %q", help)
43+
}
44+
}

cmd/chat_config_gateways.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ func (m chatModel) configGatewayRows() []configGatewayRow {
5555
return rows
5656
}
5757

58+
func (m chatModel) configGatewayRowIndex(provider string) int {
59+
provider = strings.TrimSpace(provider)
60+
for i, row := range m.configGatewayRows() {
61+
if row.ID == provider {
62+
return i
63+
}
64+
}
65+
return -1
66+
}
67+
5868
func (m chatModel) activeGatewayRowIndex(rows []configGatewayRow) int {
5969
for i, row := range rows {
6070
if row.Active {
@@ -90,7 +100,7 @@ func (m chatModel) refreshConfigGateway() (chatModel, tea.Cmd) {
90100
idx := m.configGatewayRefreshTargetIndex(rows)
91101
row := rows[idx]
92102
if !row.HasKey {
93-
m.configNotice = fmt.Sprintf("Add an API key for %s first — Keys tab → Add API key", row.DisplayName)
103+
m.configNotice = fmt.Sprintf("Select %s and press enter to paste an API key", row.DisplayName)
94104
return m, nil
95105
}
96106
m.configSaving = true
@@ -174,15 +184,43 @@ func (m chatModel) configGatewaysView() string {
174184

175185
ctx := context.Background()
176186
indent := strings.Repeat(" ", configTableIndent)
177-
if !hawkconfig.HasConfiguredDeploymentCached(ctx) {
178-
b.WriteString("\n" + mutedStyle.Render(indent+"Catalog count appears after a saved key · key required to use a gateway"))
187+
if m.configKeysPendingRemove != "" {
188+
name := hawkconfig.GatewayDisplayName(m.configKeysPendingRemove)
189+
b.WriteString("\n" + mutedStyle.Render(indent+configGatewayRemovePrompt(m.configKeysRemoveStep, name)))
190+
} else if !hawkconfig.HasConfiguredDeploymentCached(ctx) {
191+
b.WriteString("\n" + mutedStyle.Render(indent+"Select a gateway · enter · paste API key · then Models tab"))
179192
} else {
180-
b.WriteString("\n" + configTableSelectionFooter(len(rows), m.configScroll, end, mutedStyle, "r refresh · enter select · ↓ refresh row"))
193+
b.WriteString("\n" + configTableSelectionFooter(len(rows), m.configScroll, end, mutedStyle, "enter use gateway · k view key · delete remove · r refresh"))
181194
}
182195
return m.configTabShellView(b.String())
183196
}
184197

198+
func (m chatModel) selectedConfigGateway() (configGatewayRow, bool) {
199+
rows := m.configGatewayRows()
200+
if m.configSel < 0 || m.configSel >= len(rows) {
201+
return configGatewayRow{}, false
202+
}
203+
return rows[m.configSel], true
204+
}
205+
206+
func (m chatModel) handleConfigGatewaysDelete() chatModel {
207+
if row, ok := m.selectedConfigGateway(); ok && row.HasKey {
208+
return m.beginConfigGatewayKeyRemove(row.ID)
209+
}
210+
return m
211+
}
212+
213+
func (m chatModel) handleConfigGatewaysEsc() chatModel {
214+
if m.configKeysPendingRemove != "" {
215+
return m.clearConfigGatewayKeyRemove()
216+
}
217+
return m.closeConfigPanel()
218+
}
219+
185220
func (m chatModel) handleConfigGatewaysSelect() (chatModel, tea.Cmd) {
221+
if m.configKeysPendingRemove != "" {
222+
return m.advanceConfigGatewayKeyRemove()
223+
}
186224
rows := m.configGatewayRows()
187225
refreshIdx := len(rows)
188226
if m.configSel == refreshIdx {
@@ -196,10 +234,8 @@ func (m chatModel) handleConfigGatewaysSelect() (chatModel, tea.Cmd) {
196234
if row.ID == configProviderOllama {
197235
return m.startConfigOllamaURL()
198236
}
199-
m.configTab = configTabKeys
200-
m.configSel = m.configKeysAddRowIndex()
201-
m.configNotice = fmt.Sprintf("Add an API key for %s first — Keys tab → Add API key", row.DisplayName)
202-
return m, nil
237+
m.configGatewayFocus = m.configSel
238+
return m.startConfigKeyForProvider(row.ID)
203239
}
204240
gw := row.ID
205241
m.configGatewayFocus = m.configSel

cmd/chat_config_gateways_test.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,21 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/charmbracelet/bubbles/textarea"
8+
"github.com/charmbracelet/bubbles/textinput"
9+
tea "github.com/charmbracelet/bubbletea"
710
"github.com/GrayCodeAI/eyrie/credentials"
811
hawkconfig "github.com/GrayCodeAI/hawk/internal/config"
912
"github.com/GrayCodeAI/hawk/internal/engine"
1013
)
1114

15+
func chatModelForConfigPasteTest() chatModel {
16+
ti := textinput.New()
17+
ti.Width = 40
18+
ti, _ = ti.Update(tea.WindowSizeMsg{Width: 40, Height: 1})
19+
return chatModel{configInput: ti, input: textarea.New()}
20+
}
21+
1222
func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) {
1323
hawkconfig.InvalidateConfigUICache()
1424
store := &credentials.MapStore{}
@@ -26,8 +36,8 @@ func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) {
2636
if !strings.Contains(view, "key required") {
2737
t.Fatalf("expected key-required model cells without credentials, got:\n%s", view)
2838
}
29-
if !strings.Contains(view, "Catalog count appears after a saved key") {
30-
t.Fatalf("expected key requirement hint without credentials, got:\n%s", view)
39+
if !strings.Contains(view, "Select a gateway") {
40+
t.Fatalf("expected gateway-first setup hint without credentials, got:\n%s", view)
3141
}
3242
}
3343

@@ -112,7 +122,7 @@ func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) {
112122
}
113123
}
114124

115-
func TestHandleConfigGatewaysSelect_NoKeyRedirectsToKeys(t *testing.T) {
125+
func TestHandleConfigGatewaysSelect_NoKeyStartsPaste(t *testing.T) {
116126
hawkconfig.InvalidateConfigUICache()
117127
store := &credentials.MapStore{}
118128
credentials.SetDefaultStore(store)
@@ -121,15 +131,25 @@ func TestHandleConfigGatewaysSelect_NoKeyRedirectsToKeys(t *testing.T) {
121131
hawkconfig.InvalidateConfigUICache()
122132
})
123133

124-
m := chatModel{configTab: configTabGateways, configSel: 0}
125-
next, _ := m.handleConfigGatewaysSelect()
126-
if next.configTab != configTabKeys {
127-
t.Fatalf("tab = %d, want Keys", next.configTab)
134+
m := chatModelForConfigPasteTest()
135+
m.configTab = configTabGateways
136+
m.configSel = 0
137+
gwRows := m.configGatewayRows()
138+
if len(gwRows) == 0 {
139+
t.Fatal("expected gateway rows")
140+
}
141+
next, cmd := m.handleConfigGatewaysSelect()
142+
if next.configTab != configTabGateways {
143+
t.Fatalf("tab = %d, want Gateways", next.configTab)
144+
}
145+
if next.configEntry != configEntryAPIKeyPaste {
146+
t.Fatalf("entry = %q, want API key paste", next.configEntry)
128147
}
129-
if next.configSel != 0 {
130-
t.Fatalf("sel = %d, want Add API key row", next.configSel)
148+
if next.configProvider != gwRows[0].ID {
149+
t.Fatalf("provider = %q, want %s", next.configProvider, gwRows[0].ID)
131150
}
132-
if !strings.Contains(next.configNotice, "Add an API key") {
151+
if !strings.Contains(next.configNotice, "Paste API key") {
133152
t.Fatalf("notice = %q", next.configNotice)
134153
}
154+
_ = cmd
135155
}

cmd/chat_config_hub.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (m chatModel) beginConfigModelsTab() (chatModel, tea.Cmd) {
3636

3737
func (m chatModel) returnToOllamaURLAfterError(err error) (chatModel, tea.Cmd) {
3838
m.configSaving = false
39-
m.configTab = configTabKeys
39+
m.configTab = configTabGateways
4040
url := strings.TrimSpace(m.configPendingOllamaURL)
4141
if url == "" {
4242
url = configDefaultOllamaURL

0 commit comments

Comments
 (0)