| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "fmt" |
| 8 | "strings" |
| 9 | "unicode/utf8" |
| 10 | |
| 11 | tea "charm.land/bubbletea/v2" |
| 12 | "github.com/charmbracelet/x/ansi" |
| 13 | |
| 14 | "reasonix/internal/boot" |
| 15 | "reasonix/internal/config" |
| 16 | ) |
| 17 | |
| 18 | type connectionSetup struct { |
| 19 | providerName string |
| 20 | configPath string |
| 21 | revision string |
| 22 | key string |
| 23 | saving bool |
| 24 | testing bool |
| 25 | testCancel context.CancelFunc |
| 26 | testVersion uint64 |
| 27 | } |
| 28 | |
| 29 | type connectionCredentialTestedMsg struct { |
| 30 | providerName string |
| 31 | setup *connectionSetup |
| 32 | version uint64 |
| 33 | err error |
| 34 | } |
| 35 | |
| 36 | type connectionCredentialSavedMsg struct { |
| 37 | providerName string |
| 38 | result config.ConnectionCredentialResult |
| 39 | err error |
| 40 | } |
| 41 | |
| 42 | func (m *chatTUI) openConnectionSetup() { |
| 43 | cfg, err := config.LoadForRootReadOnly(m.ctrl.WorkspaceRoot()) |
| 44 | if err != nil { |
| 45 | m.notice("setup: " + err.Error()) |
| 46 | return |
| 47 | } |
| 48 | current := strings.SplitN(m.modelRef, "/", 2)[0] |
| 49 | items := make([]quickPickerItem, 0, len(cfg.Providers)) |
| 50 | selected := 0 |
| 51 | for _, entry := range cfg.Providers { |
| 52 | if strings.TrimSpace(entry.Name) == "" || len(entry.ModelList()) == 0 { |
| 53 | continue |
| 54 | } |
| 55 | status := "" |
| 56 | if entry.Name == current { |
| 57 | status = "active" |
| 58 | selected = len(items) |
| 59 | } |
| 60 | description := fmt.Sprintf("%s · %d model(s)", entry.Kind, len(entry.ModelList())) |
| 61 | if entry.RequiresAPIKey() && entry.APIKey() == "" { |
| 62 | description += " · key required" |
| 63 | } |
| 64 | items = append(items, quickPickerItem{ID: entry.Name, Label: entry.Name, Description: description, Status: status}) |
| 65 | } |
| 66 | if len(items) == 0 { |
| 67 | m.notice("setup: no model connections are available") |
| 68 | return |
| 69 | } |
| 70 | m.quickPick = &quickPicker{kind: quickPickerSetupProvider, title: "Configure connection", items: items, selected: selected} |
| 71 | } |
| 72 | |
| 73 | func (m *chatTUI) beginConnectionKeyEdit(providerName string) { |
| 74 | root := strings.TrimSpace(m.ctrl.WorkspaceRoot()) |
| 75 | // Resolve the same source precedence as runtime loading: a user provider |
| 76 | // shadows a same-named project declaration. |
| 77 | effective, err := config.LoadForRootReadOnly(root) |
| 78 | if err != nil { |
| 79 | m.notice("setup: " + err.Error()) |
| 80 | return |
| 81 | } |
| 82 | path, err := effective.ProviderEditPath(root, providerName) |
| 83 | if err != nil { |
| 84 | m.notice("setup: " + err.Error()) |
| 85 | return |
| 86 | } |
| 87 | m.setup = &connectionSetup{providerName: providerName, configPath: path, revision: config.ConfigFileRevision(path)} |
| 88 | } |
| 89 | |
| 90 | func (m chatTUI) handleConnectionSetupKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
| 91 | setup := m.setup |
| 92 | if setup == nil || setup.saving { |
| 93 | return m, nil |
| 94 | } |
| 95 | switch msg.String() { |
| 96 | case "esc": |
| 97 | if setup.testCancel != nil { |
| 98 | setup.testCancel() |
| 99 | } |
| 100 | m.setup = nil |
| 101 | return m, nil |
| 102 | case "ctrl+t": |
| 103 | if setup.testing { |
| 104 | return m, nil |
| 105 | } |
| 106 | if setup.key == "" { |
| 107 | m.notice("setup: enter an API key before testing") |
| 108 | return m, nil |
| 109 | } |
| 110 | cfg, err := config.LoadForEditReadOnlyStrict(setup.configPath) |
| 111 | if err != nil { |
| 112 | m.notice("setup: " + err.Error()) |
| 113 | return m, nil |
| 114 | } |
| 115 | entry, ok := cfg.Provider(setup.providerName) |
| 116 | if !ok { |
| 117 | m.notice("setup: connection no longer exists") |
| 118 | return m, nil |
| 119 | } |
| 120 | ctx, cancel := context.WithCancel(context.Background()) |
| 121 | setup.testVersion++ |
| 122 | version := setup.testVersion |
| 123 | setup.testing, setup.testCancel = true, cancel |
| 124 | probeEntry := *entry |
| 125 | key := setup.key |
| 126 | proxy := cfg.NetworkProxySpec() |
| 127 | return m, func() tea.Msg { |
| 128 | err := boot.ProbeProviderConnection(ctx, probeEntry, key, proxy) |
| 129 | return connectionCredentialTestedMsg{providerName: setup.providerName, setup: setup, version: version, err: err} |
| 130 | } |
| 131 | case "backspace": |
| 132 | if setup.key != "" { |
| 133 | setup.invalidateTest() |
| 134 | _, n := utf8.DecodeLastRuneInString(setup.key) |
| 135 | setup.key = setup.key[:len(setup.key)-n] |
| 136 | } |
| 137 | return m, nil |
| 138 | case "enter": |
| 139 | if setup.key == "" { |
| 140 | m.notice("setup: enter an API key") |
| 141 | return m, nil |
| 142 | } |
| 143 | setup.invalidateTest() |
| 144 | requestID, err := newConnectionSetupRequestID() |
| 145 | if err != nil { |
| 146 | m.notice("setup: " + err.Error()) |
| 147 | return m, nil |
| 148 | } |
| 149 | setup.saving = true |
| 150 | req := config.ConnectionCredentialRequest{ |
| 151 | RequestID: requestID, ConfigPath: setup.configPath, |
| 152 | ProviderNames: []string{setup.providerName}, Key: setup.key, ExpectedRevision: setup.revision, |
| 153 | } |
| 154 | return m, func() tea.Msg { |
| 155 | result, err := config.CommitConnectionCredential(req) |
| 156 | return connectionCredentialSavedMsg{providerName: setup.providerName, result: result, err: err} |
| 157 | } |
| 158 | default: |
| 159 | text := msg.Text |
| 160 | if text == "" { |
| 161 | s := msg.String() |
| 162 | if len(s) == 1 && s[0] >= 32 && s[0] < 127 { |
| 163 | text = s |
| 164 | } |
| 165 | } |
| 166 | if text != "" && !strings.ContainsAny(text, "\r\n") { |
| 167 | setup.invalidateTest() |
| 168 | setup.key += text |
| 169 | } |
| 170 | return m, nil |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func (m chatTUI) renderConnectionSetup() string { |
| 175 | if m.setup == nil { |
| 176 | return "" |
| 177 | } |
| 178 | w := max(m.width, 10) |
| 179 | contentWidth := max(w-8, 12) |
| 180 | var body strings.Builder |
| 181 | body.WriteString(accent("Configure "+m.setup.providerName) + "\n") |
| 182 | body.WriteString(" API Key\n") |
| 183 | masked := strings.Repeat("•", utf8.RuneCountInString(m.setup.key)) |
| 184 | if masked == "" { |
| 185 | masked = dim("Enter credential") |
| 186 | } |
| 187 | body.WriteString(" " + ansi.Truncate(masked, contentWidth, "…") + "\n") |
| 188 | if m.setup.saving { |
| 189 | body.WriteString(dim("Saving…")) |
| 190 | } else if m.setup.testing { |
| 191 | body.WriteString(dim("Testing connection… · Esc cancel")) |
| 192 | } else { |
| 193 | body.WriteString(dim("Ctrl+T test · Enter save · Esc cancel")) |
| 194 | } |
| 195 | return choicePanelStyle.Width(w).Render(body.String()) |
| 196 | } |
| 197 | |
| 198 | func (m *chatTUI) handleConnectionCredentialTested(msg connectionCredentialTestedMsg) { |
| 199 | if m.setup == nil || m.setup != msg.setup || m.setup.providerName != msg.providerName || m.setup.testVersion != msg.version { |
| 200 | return |
| 201 | } |
| 202 | m.setup.testing = false |
| 203 | m.setup.testCancel = nil |
| 204 | if msg.err != nil { |
| 205 | m.notice("connection test: " + msg.err.Error()) |
| 206 | return |
| 207 | } |
| 208 | m.notice("Connection test succeeded. The credential has not been saved yet.") |
| 209 | } |
| 210 | |
| 211 | func (s *connectionSetup) invalidateTest() { |
| 212 | if s == nil { |
| 213 | return |
| 214 | } |
| 215 | if s.testCancel != nil { |
| 216 | s.testCancel() |
| 217 | } |
| 218 | s.testing = false |
| 219 | s.testCancel = nil |
| 220 | s.testVersion++ |
| 221 | } |
| 222 | |
| 223 | func newConnectionSetupRequestID() (string, error) { |
| 224 | var raw [16]byte |
| 225 | if _, err := rand.Read(raw[:]); err != nil { |
| 226 | return "", fmt.Errorf("create save request: %w", err) |
| 227 | } |
| 228 | return "cli-" + hex.EncodeToString(raw[:]), nil |
| 229 | } |
| 230 | |
| 231 | func (m *chatTUI) handleConnectionCredentialSaved(msg connectionCredentialSavedMsg) tea.Cmd { |
| 232 | if msg.err != nil { |
| 233 | if m.setup != nil { |
| 234 | m.setup.saving = false |
| 235 | } |
| 236 | m.notice("setup: " + msg.err.Error()) |
| 237 | return nil |
| 238 | } |
| 239 | m.setup = nil |
| 240 | m.notice("Credential saved for " + msg.providerName + ". Applying it to this session…") |
| 241 | return m.scheduleCurrentControllerRebuild("setup", "Credential saved and applied") |
| 242 | } |
| 243 |