mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* feat(tui): actionable hints on provider/model errors Classify a failed turn's provider error into auth / rate-limit / connectivity / model-not-found / context-overflow and render a one-line next step below the red error row, instead of dumping an identical raw blob for every failure mode. The classifier lives in a new internal/errhint package so both the TUI error row and the CLI exec path can share it (TUIHint references slash commands, CLIHint references zero subcommands). Classification is a conservative string heuristic keyed off providerio.ClassifiedError's prefixes plus lower-level DNS/TLS/timeout/context-length signatures, since the numeric HTTP status is gone by the time the error reaches a UI surface. * feat(tools): budget bash output with head+tail truncation bash was the one tool that returned command output with no byte cap — 'cat large.log' or a verbose test run could dump megabytes into context and force compaction, while every other read/search tool already applies a budget. Cap stdout and stderr at 96KB each, keeping the head and tail of an oversized stream (build/test failures usually land at the tail) and dropping the middle behind a marker that points at redirect-to-file + read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in Meta like the other tools. Sandbox-denial and shell-issue detection still run on the full raw output before budgeting. * fix(gemini): retry 401 with refreshed OAuth token The Gemini provider used providerio.SendWithRetry (429/503/529 only), so an expired OAuth token surfaced as a terminal 'API key not valid' auth error while OpenAI and Anthropic silently recovered via SendWithAuthRetry's force-refresh-and-replay on 401. Plumb OAuthResolver through gemini.Options/Provider and the factory (exactly as the other two providers already do) and switch the stream send to SendWithAuthRetry. With a nil resolver (API-key users) behavior is unchanged. * fix(errhint): gate classification on provider-origin marker Address CodeRabbit review on #1: agentResponseMsg.err can also carry local failures (a tool's 'permission denied', a 'file does not exist', a config error), and broad substrings would have attached a bogus /provider or /model hint to those. Classify now returns Unknown unless the message carries a provider marker the provider layer always attaches (auth error: / rate limit error: / provider error: / provider request error: / provider stream error:). Local errors never draw a provider hint; provider errors sub-classify as before. Added local-failure test cases proving they stay Unknown. * feat(cli): actionable hint on text-mode exec provider errors zero exec printed provider failures as a bare '[zero] <raw error>' with no next step. Append a one-line hint (reusing the shared errhint classifier) for recognized provider failures — 'run `zero auth`', 'run `zero doctor`', etc. The provider-origin gate means non-provider codes (sandbox_error, mcp_error) and JSON/stream-json output are untouched. * fix(tools): set Result.Truncated for budgeted bash output Address CodeRabbit review on #3: budgetBashOutput recorded truncation in meta["truncated"] but never set the Result.Truncated struct field, so consumers reading the struct (like glob/web_fetch/read_minified do) would miss truncated bash results. Return the bool from budgetBashOutput and set Result.Truncated on every return path. * perf(tui): reuse one LSP manager across prompts runAgentWithOptions built a fresh lsp.NewManager per run and shut it down when the run returned, so gopls (and every other language server) cold-started on the first edit of every turn — 200ms-2s of latency the manager's own design (long-lived, reused servers) exists to avoid. Build one manager per session in newModel (cheap: servers start lazily on first Check) and reuse it across runs; the SelfCorrector still gets a fresh checker wrapping the shared manager each run. Torn down with a short deadline in quit(). Runs fall back to a per-run manager when the session manager is absent (cwd unknown, or a directly-built test model). * perf(tui): accumulate streamingText as []byte (O(1) append) m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long generation (a 10k-token code gen allocates hundreds of MB of intermediate strings and the UI gets progressively laggier). Accumulate into a []byte with append (O(1) amortized) and read via streamingTextString(). A []byte rather than strings.Builder because the TUI model is copied by value on every Update, which would trip strings.Builder's copy check; []byte is nil-safe and copies cleanly like the other slice fields. * perf(tui): coalesce streamed text deltas to one frame Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s) drove 100+ full Update->View cycles per second, each re-parsing the growing markdown — visible stutter over SSH and wasted CPU. Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and forward them as a single message, decoupling render rate from token rate. Any non-text message flushes pending text first so ordering with tool-call / reasoning / row messages is preserved; the final agentResponseMsg (a tea.Cmd return, not a sink message) is safe because the model already drops deltas for an inactive runID. * fix(agent): raise tool-failure stop threshold 4->6 guardrails.go halted a run after 4 consecutive same-error tool failures. A corrective hint fires at 2, and a model iterating on a genuinely tricky edit can legitimately fail a couple more times after the hint while converging — stopping at 4 cut those runs short. Raise to 6; the streak still resets the instant the tool succeeds or hits a different error, so only true same-error loops are affected. * fix(agent): more reconnect retries with jitter; narrow to transport errors #19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the exponential backoff (capped at 8s), so a multi-second network blip is ridden out instead of killing the run on a 2s hiccup, and concurrent runs (swarms, cron fleets) don't reconnect in lockstep. #34: drop the '502'/'503' substring matches from shouldReconnect. 503 already exhausted providerio.SendWithRetry (retrying here is a redundant double-retry) and 502 is non-idempotent by providerio's rule (the POST may have been processed). Only genuine transport failures — where no response was received — reconnect now. * fix(agent): reduce stall retries 2->1 to bound stuck-session time A no-output stream stall is detected only after the full stream idle timeout (~5min) elapses, and each retry can idle again — so 2 retries left an interactive session frozen for ~15min. Drop to 1 retry: keeps the common single-hiccup recovery while bounding the worst case to ~2x the idle timeout. * fix(tui): serialize coalescer forwarding to preserve message order Address CodeRabbit review on #7: flush() and the run-switch path drained the text buffer under c.mu but called forward() after releasing it, so a timer-fired text flush could race a concurrent non-text send() and land after the tool-call/reasoning message it should precede. Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever holds the lock delivers atomically and the other caller blocks until it is done — text can never overtake a following non-text message. Added a concurrency stress test (timer racing an inline send) that passes under -race. * feat(tui): accept /compact now Bare /compact already triggers compaction, but /compact now — what users reach for when the context gauge climbs — hit the usage-error branch. Accept the 'now' keyword and advertise it in the command usage. * feat(tui): add /retry, /edit, /copy, /export commands /retry resends the last prompt; /edit recalls it into the composer to tweak and resend — both read a new lastPrompt field captured verbatim (pre-expansion) in launchPrompt. /copy puts the last answer on the clipboard via the existing copy machinery; /export writes a plain role-prefixed transcript to a file (timestamped default, or a given path). Real gaps for SSH users with no mouse select. * feat(tools): separator-insensitive tool_search matching tool_search ranked deferred tools by exact substring, so a model that typed 'webfetch' (dropping the underscore) matched nothing and looped. Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored below an exact substring match, so precise queries still rank first. * feat(tools): signal ask_user dismissal distinctly from a blank field Empty answers all rendered as '(no answer provided)', so the model couldn't tell a wholesale dismissal (user closed the prompt without answering anything) from one field left blank amid real answers — and might invent a default. FormatAskUserAnswers now flags a full dismissal up front as a skip and marks individual empties '(left blank)' vs '(skipped)'. * feat(tui): fuzzy ranking in model/session pickers The pickers filtered by flat strings.Contains with no ranking, so finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling. Rank matches (exact < prefix < contains < subsequence, reusing fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best match, never split — so the grouped model picker still renders one header per provider. Covers both /model and /resume (shared commandPicker). * perf(agent): cache per-tool schema render across turns partitionTools re-ran the recursive schema->map conversion (schemaToRuntimeMap) for every tool on every turn — redundant, since a tool's advertised name/description/schema is stable for the run. Add a per-run definition cache keyed by tool name (nil-safe; the plain partitionTools entrypoint and tests pass nil for fresh renders). The partitioning itself (visibility, deferral, ordering) still recomputes every turn — it must, because a tool's deferred state can flip mid-run (swarm tools un-defer once a swarm is active). Only the expensive schema render is memoized. tool_search is excluded by its callers (dynamic description) so it never poisons the cache. * perf(agent): calibrate compaction estimate against real prompt tokens The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content ~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true capacity — premature summarizer calls that degrade quality. Each turn now folds (rawEstimate, provider InputTokens) into an EMA calibration ratio, clamped per-sample to a sane band; maybeCompact and the reactive path scale their estimates by it. Turn 1 uses the raw estimate (no data yet); later turns compact near real capacity. * style: gofmt the new agent test files The CI gofmt check flagged the #22/#25 test files (struct-field and switch alignment). No behavior change. * feat(agent): turn-based plan staleness reminder The stale-plan nudge only fired after 10 tool calls since the last update_plan, so a plan that drifts stale across many low-tool-call turns (one call per turn) took many turns to catch — the 'plan set turn 1 stays pending forever' case. Add a turn-count complement: after stalePlanTurnThreshold (8) turns without an update AND with items still pending, fire the same one-shot reminder. Gated on pending items so a fully-completed plan is never nagged; the existing tool-call trigger is unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items' relevance match is deliberately omitted — too false-positive-prone.) * feat(agent): preserve recent-edits summary across compaction Compaction preserved the plan, loaded skills, tool schemas, and project instructions, but not file edits — so after the editing turns were elided the model knew it had changed a file only vaguely, and would re-read to rediscover its own footprint. Extract write_file/edit_file targets from the elided middle with a one-line note from each tool result, merge them across repeated compactions (newer note per path wins), and carry them in the preserved-state JSON (capped at 20 paths / 160 bytes per note). * feat(tui): workspace-scoped /resume + fewer event reads /resume listed sessions from every project globally, and read the full event file for each of them to test for resumable content. Filter the picker (and /resume latest) by the current workspace Cwd, checked BEFORE the per-session read so a large global history no longer pays N full file reads to build one workspace's list; also skip zero-event sessions via metadata without any read. Sessions with no recorded Cwd (older runs) and an unknown current workspace stay visible so nothing is hidden that can't be confidently placed. Explicit /resume <id> still resolves any session. * feat(tui): warn on model switch that drops staged images Staged images were silently dropped at submit when the active model had no vision support — the user only learned after sending. Chips already render above the composer (renderAttachmentChips); add an immediate amber warning line to the /model switch status (both same-provider and cross-provider paths) when images are staged and the newly selected model can't accept them, so the drop is surfaced at switch time. * feat(tui): inline recovery affordance on setup errors A first-run setup error rendered as a bare red 'error: <text>' with no pointer to the fix. Append a faint, stage-tailored recovery line (edit the endpoint/name/key above then Enter, or pick another model) that names only keys the stage's footer confirms, so the guidance is always accurate. Empty for stages whose footer already makes recovery obvious. * fix: address CodeRabbit review (5 findings) - loop.go: recompute the compaction calibration estimate at calibration time, not request-build time, so a reactive-compaction reissue that shrinks the request feeds the EMA the sample that actually completed. - errhint: match bare HTTP status codes (401/403/429/529) only at digit boundaries, so an incidental number ('4290ms', 'id 14015') isn't mis-bucketed as auth/rate-limit. - bash: drop the stray unmatched ']' in the output-truncation marker. - tui /retry: apply the same exiting/compactInFlight guards commandPrompt has, so a retry can't race compactResultMsg's rewrite of session state. - tui /export: write the transcript 0o600 (it may contain echoed secrets), not world/group-readable 0o644. * test(tui): skip export file-mode assertion on Windows os.WriteFile ignores Unix permission bits on Windows and Stat reports 0666, so the 0o600 assertion failed there. The production 0o600 is honored on Linux/macOS (where it matters); guard the check with runtime.GOOS so Windows smoke passes. * fix(tui): align /resume latest filters + case-insensitive workspace match on Windows Address CodeRabbit review on #21: - latestResumableInWorkspace now applies the SAME filters as the picker (skip zero-event metadata and empty/failed runs), so /resume latest can't land on a session the picker intentionally hides. - sessionMatchesWorkspace compares paths case-insensitively on Windows (its filesystem is), so the same workspace spelled with different casing no longer hides resumable sessions; other platforms stay case-sensitive. * fix(tui): /retry resends the last prompt's image/PDF attachments /retry relaunched only lastPrompt's text, but launchPrompt clears the pending image/document queues once a turn is sent. A vision- or PDF-backed prompt that failed would silently retry as text-only and answer a different task. Snapshot the consumed attachments at launch and re-stage them on /retry so launchPrompt rebuilds an identical request (document preamble, images, and the submit-time vision re-check). startNewSession drops the snapshot so a post-/new /retry cannot leak the previous session's attachments. Also correct the OAuthResolver factory comment, which omitted the Gemini provider it is now passed to. * fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test) - reconnect: exclude HTTP 5xx (500/502/503/504) before the transport substring match so "504 Gateway Timeout" no longer slips through on the generic "timeout" needle. A gateway timeout is non-idempotent — the completion POST may already have reached the model — so replaying the connect risked duplicate billable work. Reuses a digit-boundary status matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay non-reconnectable. - tui /edit: re-stage the remembered image/PDF snapshot alongside the recalled prompt text, mirroring the /retry fix — editing a vision- or document-backed prompt no longer silently resends a text-only version. - compaction recent-edits: order edits by LAST edit, not first, and move a re-touched path to the newest position on merge, so the maxRecentEdits tail cap keeps the file the model most recently edited instead of dropping it. Adds mergeRecentEdits (edit-specific) and a capped-list regression. - coalescer test: inject the frame timer via afterFunc so TestCoalescerBatchesDeltas proves buffering deterministically instead of racing the real 16ms wall-clock timer. * fix(tools): bound bash output capture in memory, not just model-visible text Previously stdout/stderr streamed into unbounded bytes.Buffers and were only truncated to the 96 KiB budget AFTER the command finished, so a runaway command (cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer that keeps only the head+tail each stream will surface and discards the middle as it arrives, while counting the true total for the truncation marker. Memory is now bounded to ~head+2×tail per stream regardless of output size. truncateHeadTail is refactored onto a total-aware core so the string path (and its tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though only a bounded slice was held. Adds boundedBuffer/capture unit tests and an end-to-end test that streams ~5× the budget and asserts bounded emission with an accurate raw_bytes.
1736 lines
53 KiB
Go
1736 lines
53 KiB
Go
package tui
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"charm.land/bubbles/v2/textinput"
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"github.com/Gitlawb/zero/internal/browser"
|
||
"github.com/Gitlawb/zero/internal/config"
|
||
"github.com/Gitlawb/zero/internal/oauth"
|
||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||
"github.com/Gitlawb/zero/internal/redaction"
|
||
)
|
||
|
||
type setupStage int
|
||
|
||
const (
|
||
setupStageWelcome setupStage = iota
|
||
setupStageMethod
|
||
setupStageProvider
|
||
setupStageEndpoint
|
||
setupStageName
|
||
setupStageCredentials
|
||
setupStageModel
|
||
setupStageSafety
|
||
setupStageReady
|
||
)
|
||
|
||
type setupState struct {
|
||
visible bool
|
||
required bool
|
||
configPath string
|
||
providers []SetupProviderOption // active list (full, or OAuth-only after the chooser)
|
||
allProviders []SetupProviderOption // full catalog (restored when leaving the OAuth path)
|
||
selected int
|
||
selectedMethod int
|
||
oauthMode bool
|
||
oauthPending bool
|
||
oauthErr string
|
||
// Device-code login (RFC 8628) state while an OAuth login is in flight.
|
||
oauthDevice bool
|
||
deviceUserCode string
|
||
deviceVerificationURI string
|
||
stage setupStage
|
||
err string
|
||
baseURL string
|
||
name string
|
||
apiKey textinput.Model
|
||
models []providerWizardModel
|
||
modelIndex int
|
||
modelQuery string
|
||
modelForID string
|
||
modelLoad bool
|
||
modelErr string
|
||
modelSrc string
|
||
modelGen uint64
|
||
}
|
||
|
||
// setupOAuthMsg carries the result of a first-run browser OAuth login.
|
||
type setupOAuthMsg struct {
|
||
apiKey string
|
||
tokenLogin bool
|
||
providerID string
|
||
err error
|
||
}
|
||
|
||
// setupOAuthProviderOptions filters the full provider list to the OAuth-capable
|
||
// ones for the OAuth method path. ChatGPT/Claude are not here — they can't do
|
||
// real in-app OAuth (use "browse" + a local proxy); see docs/oauth-subscriptions.md.
|
||
func setupOAuthProviderOptions(all []SetupProviderOption) []SetupProviderOption {
|
||
out := []SetupProviderOption{}
|
||
for _, option := range all {
|
||
if descriptor, ok := providercatalog.Get(option.ID); ok && descriptor.OAuth {
|
||
out = append(out, option)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
type setupModelsDiscoveredMsg struct {
|
||
providerID string
|
||
gen uint64
|
||
redactionSecrets []string
|
||
models []providermodeldiscovery.Model
|
||
err error
|
||
}
|
||
|
||
func newSetupState(options SetupOptions) setupState {
|
||
providers := append([]SetupProviderOption{}, options.Providers...)
|
||
if len(providers) == 0 {
|
||
providers = []SetupProviderOption{{
|
||
ID: "openai",
|
||
Name: "OpenAI",
|
||
DefaultModel: "gpt-4.1",
|
||
EnvVar: "OPENAI_API_KEY",
|
||
RequiresAuth: true,
|
||
}}
|
||
}
|
||
apiKey := textinput.New()
|
||
apiKey.Prompt = ""
|
||
apiKey.Placeholder = "paste key or leave blank"
|
||
apiKey.EchoMode = textinput.EchoPassword
|
||
apiKey.EchoCharacter = '*'
|
||
// Bubble's Ctrl+V binding reads the clipboard itself. Keep it disabled so
|
||
// terminal bracketed paste (Paste: true) is the single paste path.
|
||
apiKey.KeyMap.Paste.SetEnabled(false)
|
||
apiKey.Focus()
|
||
return setupState{
|
||
visible: options.Visible,
|
||
required: options.Required,
|
||
configPath: strings.TrimSpace(options.ConfigPath),
|
||
providers: providers,
|
||
allProviders: providers,
|
||
apiKey: apiKey,
|
||
}
|
||
}
|
||
|
||
func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
m.clearMouseSelection()
|
||
// While a browser OAuth login is in flight, ignore input except Ctrl+C (quit)
|
||
// and Esc (cancel back to the OAuth provider list).
|
||
if m.setup.oauthPending {
|
||
switch {
|
||
case keyCtrl(msg, 'c'):
|
||
return m, tea.Quit
|
||
case keyIs(msg, tea.KeyEsc):
|
||
m.setup.oauthPending = false
|
||
m.setup.oauthDevice = false
|
||
}
|
||
return m, nil
|
||
}
|
||
if m.setupEndpointInputActive() {
|
||
return m.handleSetupEndpointKey(msg)
|
||
}
|
||
if m.setupNameInputActive() {
|
||
return m.handleSetupNameKey(msg)
|
||
}
|
||
if m.setupCredentialInputActive() {
|
||
return m.handleSetupCredentialKey(msg)
|
||
}
|
||
switch {
|
||
case keyCtrl(msg, 'c'):
|
||
return m, tea.Quit
|
||
case keyIs(msg, tea.KeyEsc):
|
||
if m.setup.stage > setupStageWelcome {
|
||
prev := m.previousSetupStage()
|
||
if prev == setupStageMethod {
|
||
m = m.setupReturnToMethod()
|
||
}
|
||
m.setup.stage = prev
|
||
m.setup.err = ""
|
||
return m, nil
|
||
}
|
||
if m.setup.required {
|
||
return m, tea.Quit
|
||
}
|
||
return m.exitSetupToChat()
|
||
case keyIs(msg, tea.KeyLeft):
|
||
if m.setup.stage > setupStageWelcome {
|
||
prev := m.previousSetupStage()
|
||
if prev == setupStageMethod {
|
||
m = m.setupReturnToMethod()
|
||
}
|
||
m.setup.stage = prev
|
||
m.setup.err = ""
|
||
}
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyEnter):
|
||
if m.setup.stage == setupStageMethod || m.setup.stage == setupStageProvider || m.setup.stage == setupStageModel || m.setup.stage == setupStageReady {
|
||
return m.advanceSetup()
|
||
}
|
||
return m, nil
|
||
case keyIs(msg, tea.KeySpace):
|
||
if m.setup.stage < setupStageReady && m.setup.stage != setupStageProvider && m.setup.stage != setupStageModel {
|
||
return m.advanceSetup()
|
||
}
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyUp):
|
||
if m.setup.stage == setupStageMethod {
|
||
m.moveSetupMethod(-1)
|
||
} else if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(-1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(-1)
|
||
}
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyDown):
|
||
if m.setup.stage == setupStageMethod {
|
||
m.moveSetupMethod(1)
|
||
} else if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(1)
|
||
}
|
||
return m, nil
|
||
case keyText(msg) != "":
|
||
if m.setup.stage == setupStageModel {
|
||
m.appendSetupModelQuery(keyRunes(msg))
|
||
return m, nil
|
||
}
|
||
switch keyText(msg) {
|
||
case "q":
|
||
return m, tea.Quit
|
||
case "k":
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(-1)
|
||
}
|
||
case "j":
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(1)
|
||
}
|
||
case "d", "D":
|
||
// Force device-code login for a device-capable OAuth provider.
|
||
if m.setup.stage == setupStageProvider && m.setup.oauthMode && m.setupProviderDescriptor().OAuthDeviceFlow {
|
||
return m.startSetupDeviceLogin(m.setupProviderDescriptor())
|
||
}
|
||
}
|
||
return m, nil
|
||
case keyBackspace(msg):
|
||
if m.setup.stage == setupStageModel {
|
||
m.deleteSetupModelQueryRune()
|
||
}
|
||
return m, nil
|
||
case keyCtrl(msg, 'u'):
|
||
if m.setup.stage == setupStageModel {
|
||
m.setup.modelQuery = ""
|
||
m.setup.modelIndex = 0
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
switch msg.String() {
|
||
case " ":
|
||
if m.setup.stage < setupStageReady && m.setup.stage != setupStageProvider && m.setup.stage != setupStageModel {
|
||
return m.advanceSetup()
|
||
}
|
||
case "q":
|
||
return m, tea.Quit
|
||
case "k":
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(-1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(-1)
|
||
}
|
||
case "j":
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(1)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) handleSetupPaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
|
||
if m.setup.oauthPending {
|
||
return m, nil
|
||
}
|
||
switch {
|
||
case m.setupEndpointInputActive():
|
||
m.appendSetupBaseURL([]rune(msg.Content))
|
||
case m.setupNameInputActive():
|
||
m.appendSetupName([]rune(msg.Content))
|
||
case m.setupCredentialInputActive():
|
||
previousAPIKey := m.setup.apiKey.Value()
|
||
var cmd tea.Cmd
|
||
m.setup.apiKey, cmd = m.setup.apiKey.Update(msg)
|
||
if m.setup.apiKey.Value() != previousAPIKey {
|
||
m.setup.modelGen++
|
||
m.resetSetupModels()
|
||
}
|
||
m.setup.err = ""
|
||
return m, cmd
|
||
case m.setup.stage == setupStageModel:
|
||
m.appendSetupModelQuery([]rune(msg.Content))
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) handleSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||
if m.setup.oauthPending {
|
||
return m, nil
|
||
}
|
||
// A right-click pastes the clipboard into the focused setup field, mirroring
|
||
// handleMouse (mouse.go) so paste behaves identically in setup mode — routePaste
|
||
// targets the active setup input. Without this branch, setup swallows the
|
||
// right-click and paste never fires.
|
||
if mouseRightPress(msg) {
|
||
return m, pasteFromClipboardCmd()
|
||
}
|
||
if mouseLeftPress(msg) {
|
||
switch m.setup.stage {
|
||
case setupStageProvider:
|
||
if target, ok := m.selectSetupProviderAtMouse(msg); ok {
|
||
if m.repeatMouseSelection(target) {
|
||
m.clearMouseSelection()
|
||
return m.advanceSetup()
|
||
}
|
||
m.lastMouseSelection = target
|
||
return m, nil
|
||
}
|
||
case setupStageModel:
|
||
if target, ok := m.selectSetupModelAtMouse(msg); ok {
|
||
if m.repeatMouseSelection(target) {
|
||
m.clearMouseSelection()
|
||
return m.advanceSetup()
|
||
}
|
||
m.lastMouseSelection = target
|
||
return m, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
switch {
|
||
case mouseWheelUp(msg):
|
||
m.clearMouseSelection()
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(-1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(-1)
|
||
}
|
||
case mouseWheelDown(msg):
|
||
m.clearMouseSelection()
|
||
if m.setup.stage == setupStageProvider {
|
||
m.moveSetupProvider(1)
|
||
} else if m.setup.stage == setupStageModel {
|
||
m.moveSetupModel(1)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *model) selectSetupProviderAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
|
||
if len(m.setup.providers) == 0 {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
width := chatWidth(m.width)
|
||
height := normalizedStartupHeight(m.height)
|
||
rowWidth := setupProviderBlockWidth(width, m.setup.providers)
|
||
if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
maxVisible := setupProviderMaxVisible(height, len(m.setup.providers))
|
||
if maxVisible == 0 {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
content := m.setupProviderLines(width, height)
|
||
top := setupContentTop(height, len(content), m.setup.err != "")
|
||
row := mouseY(msg) - top - 2
|
||
if row < 0 || row >= maxVisible {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
start := selectableListStart(len(m.setup.providers), maxVisible, m.setup.selected)
|
||
index := start + row
|
||
if index < 0 || index >= len(m.setup.providers) {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
m.setup.selected = index
|
||
m.setup.apiKey.SetValue("")
|
||
m.setup.baseURL = ""
|
||
m.setup.name = ""
|
||
m.setup.modelGen++
|
||
m.resetSetupModels()
|
||
return mouseSelectionTarget{Scope: "first-run-provider", Value: m.setup.providers[index].ID, Index: index}, true
|
||
}
|
||
|
||
func (m *model) selectSetupModelAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
|
||
if m.setup.modelLoad {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
m.ensureSetupModels()
|
||
models := m.setupFilteredModels()
|
||
if len(models) == 0 {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
width := chatWidth(m.width)
|
||
height := normalizedStartupHeight(m.height)
|
||
rowWidth := setupModelBlockWidth(width, m.setup.models)
|
||
if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
maxVisible := setupModelMaxVisible(height, len(models))
|
||
if maxVisible == 0 {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
m.setup.modelIndex = clampInt(m.setup.modelIndex, 0, len(models)-1)
|
||
content := m.setupModelLines(width, height)
|
||
top := setupContentTop(height, len(content), m.setup.err != "")
|
||
rowStart := 4
|
||
if m.setupModelStatus() != "" {
|
||
rowStart++
|
||
}
|
||
row := mouseY(msg) - top - rowStart
|
||
if row < 0 || row >= maxVisible {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
start := selectableListStart(len(models), maxVisible, m.setup.modelIndex)
|
||
index := start + row
|
||
if index < 0 || index >= len(models) {
|
||
return mouseSelectionTarget{}, false
|
||
}
|
||
m.setup.modelIndex = index
|
||
return mouseSelectionTarget{Scope: "first-run-model", Value: models[index].ID, Index: index}, true
|
||
}
|
||
|
||
func setupContentTop(height int, contentLines int, hasError bool) int {
|
||
if hasError {
|
||
contentLines += 2
|
||
}
|
||
return maxInt(0, (height-contentLines-3)/2)
|
||
}
|
||
|
||
func (m model) setupStages() []setupStage {
|
||
stages := []setupStage{setupStageWelcome, setupStageMethod}
|
||
if m.setup.oauthMode {
|
||
// OAuth path skips endpoint/name/credentials (the login provides the credential).
|
||
return append(stages, setupStageProvider, setupStageModel, setupStageSafety, setupStageReady)
|
||
}
|
||
stages = append(stages, setupStageProvider)
|
||
if setupProviderNeedsEndpoint(m.setupProvider()) {
|
||
stages = append(stages, setupStageEndpoint, setupStageName)
|
||
}
|
||
stages = append(stages, setupStageCredentials, setupStageModel, setupStageSafety, setupStageReady)
|
||
return stages
|
||
}
|
||
|
||
// setupReturnToMethod resets the OAuth selection when navigating back to the
|
||
// connect-method chooser.
|
||
func (m model) setupReturnToMethod() model {
|
||
m.setup.oauthMode = false
|
||
m.setup.oauthErr = ""
|
||
m.setup.oauthDevice = false
|
||
m.setup.deviceUserCode = ""
|
||
m.setup.deviceVerificationURI = ""
|
||
m.setup.providers = m.setup.allProviders
|
||
m.setup.selected = 0
|
||
m.resetSetupModels()
|
||
return m
|
||
}
|
||
|
||
// setupMethodOptions returns the connect-method choices for this run, dropping the
|
||
// OAuth option when this setup's own provider list has no OAuth-capable entry — so
|
||
// the user can't pick OAuth and land on an empty provider list.
|
||
func (m model) setupMethodOptions() []providerWizardMethodOption {
|
||
options := providerWizardMethodOptions()
|
||
if len(setupOAuthProviderOptions(m.setup.allProviders)) > 0 {
|
||
return options
|
||
}
|
||
filtered := make([]providerWizardMethodOption, 0, len(options))
|
||
for _, option := range options {
|
||
if option.oauth {
|
||
continue
|
||
}
|
||
filtered = append(filtered, option)
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func (m *model) moveSetupMethod(delta int) {
|
||
options := m.setupMethodOptions()
|
||
if len(options) == 0 {
|
||
return
|
||
}
|
||
m.setup.selectedMethod = ((m.setup.selectedMethod+delta)%len(options) + len(options)) % len(options)
|
||
}
|
||
|
||
// setupOAuthCmd runs the chosen provider's browser OAuth login off the UI
|
||
// goroutine for first-run setup. Mirrors the /provider wizard's flow.
|
||
func setupOAuthCmd(provider providercatalog.Descriptor) tea.Cmd {
|
||
switch {
|
||
case provider.OAuthMintsKey:
|
||
return func() tea.Msg {
|
||
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
|
||
OpenBrowser: browser.OpenURL,
|
||
Timeout: 3 * time.Minute,
|
||
})
|
||
return setupOAuthMsg{apiKey: key, providerID: provider.ID, err: err}
|
||
}
|
||
case provider.ID == "chatgpt":
|
||
return func() tea.Msg {
|
||
err := runProviderChatGPTLogin()
|
||
return setupOAuthMsg{tokenLogin: true, providerID: provider.ID, err: err}
|
||
}
|
||
default:
|
||
name := provider.ID
|
||
return func() tea.Msg {
|
||
return setupOAuthMsg{tokenLogin: true, providerID: name, err: runProviderTokenLogin(name)}
|
||
}
|
||
}
|
||
}
|
||
|
||
// setupOAuthDeviceMsg carries phase 1 of device-code login (RequestDeviceCode)
|
||
// for first-run setup: the user_code + verification URI to display.
|
||
type setupOAuthDeviceMsg struct {
|
||
providerID string
|
||
userCode string
|
||
verifyURL string
|
||
cfg oauth.Config
|
||
auth oauth.DeviceAuth
|
||
err error
|
||
}
|
||
|
||
func setupDevicePrepareCmd(name string) tea.Cmd {
|
||
return func() tea.Msg {
|
||
auth, cfg, err := oauthDevicePrepare(name)
|
||
if err != nil {
|
||
return setupOAuthDeviceMsg{providerID: name, err: err}
|
||
}
|
||
return setupOAuthDeviceMsg{
|
||
providerID: name,
|
||
userCode: auth.UserCode,
|
||
verifyURL: oauthDeviceVerifyTarget(auth),
|
||
cfg: cfg,
|
||
auth: auth,
|
||
}
|
||
}
|
||
}
|
||
|
||
func setupDevicePollCmd(name string, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd {
|
||
return func() tea.Msg {
|
||
return setupOAuthMsg{tokenLogin: true, providerID: name, err: oauthDeviceComplete(name, cfg, auth)}
|
||
}
|
||
}
|
||
|
||
// startSetupDeviceLogin begins the device-code flow for the selected OAuth
|
||
// provider during first-run setup (phase 1).
|
||
func (m model) startSetupDeviceLogin(descriptor providercatalog.Descriptor) (tea.Model, tea.Cmd) {
|
||
if !descriptor.OAuth || !descriptor.OAuthDeviceFlow {
|
||
return m, nil
|
||
}
|
||
m.setup.oauthPending = true
|
||
m.setup.oauthDevice = true
|
||
m.setup.oauthErr = ""
|
||
m.setup.deviceUserCode = ""
|
||
m.setup.deviceVerificationURI = ""
|
||
return m, setupDevicePrepareCmd(descriptor.ID)
|
||
}
|
||
|
||
// applySetupOAuthDeviceCode handles phase 1 of device-code login: show the code,
|
||
// then start phase 2 (the token poll). On error the redacted message is shown.
|
||
func (m model) applySetupOAuthDeviceCode(msg setupOAuthDeviceMsg) (tea.Model, tea.Cmd) {
|
||
if !m.setup.visible || !m.setup.oauthPending {
|
||
return m, nil
|
||
}
|
||
// Ignore a stale result from a login the user has since replaced with a
|
||
// different provider (an in-flight prepare landing after the switch).
|
||
if msg.providerID != "" && msg.providerID != m.setupProviderDescriptor().ID {
|
||
return m, nil
|
||
}
|
||
if msg.err != nil {
|
||
m.setup.oauthPending = false
|
||
m.setup.oauthDevice = false
|
||
m.setup.oauthErr = redaction.RedactString(msg.err.Error(), redaction.Options{})
|
||
return m, nil
|
||
}
|
||
m.setup.deviceUserCode = msg.userCode
|
||
m.setup.deviceVerificationURI = msg.verifyURL
|
||
return m, setupDevicePollCmd(msg.providerID, msg.cfg, msg.auth)
|
||
}
|
||
|
||
// applySetupOAuth folds an OAuth login result into the first-run setup: on success
|
||
// the credential is captured (minted key) or relied upon (stored token) and setup
|
||
// jumps to model selection; on failure the redacted error is shown.
|
||
func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
|
||
if !m.setup.visible || !m.setup.oauthPending {
|
||
return m, nil
|
||
}
|
||
// Ignore a stale result for a provider the user has since switched away from,
|
||
// so a late login can't capture a credential against the wrong provider.
|
||
if msg.providerID != "" && msg.providerID != m.setupProviderDescriptor().ID {
|
||
return m, nil
|
||
}
|
||
m.setup.oauthPending = false
|
||
if msg.err != nil {
|
||
m.setup.oauthErr = redaction.RedactString(msg.err.Error(), redaction.Options{})
|
||
return m, nil
|
||
}
|
||
if msg.apiKey != "" {
|
||
m.setup.apiKey.SetValue(msg.apiKey)
|
||
}
|
||
m.setup.oauthErr = ""
|
||
m.setup.err = ""
|
||
m.setup.oauthDevice = false
|
||
m.setup.deviceUserCode = ""
|
||
m.setup.deviceVerificationURI = ""
|
||
m.setup.stage = setupStageModel
|
||
m.resetSetupModels()
|
||
m.setup.modelErr = ""
|
||
m.setup.modelGen++
|
||
cmd := m.setupModelDiscoveryCmd(m.setup.modelGen)
|
||
m.setup.modelLoad = cmd != nil
|
||
return m, cmd
|
||
}
|
||
|
||
func (m model) nextSetupStage() setupStage {
|
||
stages := m.setupStages()
|
||
for index, stage := range stages {
|
||
if stage == m.setup.stage {
|
||
if index+1 < len(stages) {
|
||
return stages[index+1]
|
||
}
|
||
return stage
|
||
}
|
||
}
|
||
return m.setup.stage
|
||
}
|
||
|
||
func (m model) previousSetupStage() setupStage {
|
||
stages := m.setupStages()
|
||
for index, stage := range stages {
|
||
if stage == m.setup.stage {
|
||
if index > 0 {
|
||
return stages[index-1]
|
||
}
|
||
return stage
|
||
}
|
||
}
|
||
return m.setup.stage
|
||
}
|
||
|
||
func setupBlockContainsMouseX(x int, width int, blockWidth int) bool {
|
||
if blockWidth <= 0 {
|
||
return false
|
||
}
|
||
left := maxInt(0, (width-blockWidth)/2)
|
||
return x >= left && x < left+blockWidth
|
||
}
|
||
|
||
func (m model) advanceSetup() (tea.Model, tea.Cmd) {
|
||
if m.setup.stage < setupStageReady {
|
||
if m.setup.stage == setupStageMethod {
|
||
options := m.setupMethodOptions()
|
||
m.setup.selectedMethod = clamp(m.setup.selectedMethod, 0, maxInt(0, len(options)-1))
|
||
if len(options) > 0 && options[m.setup.selectedMethod].oauth {
|
||
m.setup.oauthMode = true
|
||
m.setup.providers = setupOAuthProviderOptions(m.setup.allProviders)
|
||
} else {
|
||
m.setup.oauthMode = false
|
||
m.setup.providers = m.setup.allProviders
|
||
}
|
||
m.setup.selected = 0
|
||
m.resetSetupModels()
|
||
m.setup.stage = setupStageProvider
|
||
m.setup.err = ""
|
||
return m, nil
|
||
}
|
||
// OAuth path: advancing from the OAuth provider list starts the browser login.
|
||
if m.setup.stage == setupStageProvider && m.setup.oauthMode {
|
||
descriptor := m.setupProviderDescriptor()
|
||
if descriptor.OAuth {
|
||
// Headless/SSH boxes can't open a browser — use device code there
|
||
// by default (the user can also force it with "d" from the list).
|
||
if descriptor.OAuthDeviceFlow && oauthPreferDeviceFlow() {
|
||
return m.startSetupDeviceLogin(descriptor)
|
||
}
|
||
m.setup.oauthPending = true
|
||
m.setup.oauthDevice = false
|
||
m.setup.oauthErr = ""
|
||
return m, setupOAuthCmd(descriptor)
|
||
}
|
||
}
|
||
if m.setup.stage == setupStageProvider {
|
||
m.setup.apiKey.SetValue("")
|
||
m.setup.baseURL = ""
|
||
m.setup.name = ""
|
||
}
|
||
if m.setup.stage == setupStageEndpoint {
|
||
if err := providerWizardEndpointError(m.setup.baseURL); err != "" {
|
||
m.setup.err = err
|
||
return m, nil
|
||
}
|
||
}
|
||
if m.setup.stage == setupStageModel && m.setup.modelLoad {
|
||
m.setup.err = "Models are still loading."
|
||
return m, nil
|
||
}
|
||
if m.setup.stage == setupStageModel && setupProviderUsesTypedModel(m.setupProvider()) && strings.TrimSpace(m.setup.modelQuery) == "" {
|
||
m.setup.err = "Enter a model name before continuing."
|
||
return m, nil
|
||
}
|
||
if m.setup.stage == setupStageModel && m.setupCurrentModel().ID == "" {
|
||
m.setup.err = "Choose a matching model before continuing."
|
||
return m, nil
|
||
}
|
||
m.setup.stage = m.nextSetupStage()
|
||
m.setup.err = ""
|
||
if m.setup.stage == setupStageModel {
|
||
m.resetSetupModels()
|
||
m.setup.modelErr = ""
|
||
m.setup.modelGen++
|
||
cmd := m.setupModelDiscoveryCmd(m.setup.modelGen)
|
||
m.setup.modelLoad = cmd != nil
|
||
return m, cmd
|
||
}
|
||
return m, nil
|
||
}
|
||
return m.completeSetup()
|
||
}
|
||
|
||
func (m *model) moveSetupProvider(delta int) {
|
||
if len(m.setup.providers) == 0 {
|
||
return
|
||
}
|
||
m.setup.selected = ((m.setup.selected+delta)%len(m.setup.providers) + len(m.setup.providers)) % len(m.setup.providers)
|
||
m.setup.apiKey.SetValue("")
|
||
m.setup.baseURL = ""
|
||
m.setup.name = ""
|
||
m.setup.modelGen++
|
||
m.resetSetupModels()
|
||
}
|
||
|
||
func (m model) completeSetup() (tea.Model, tea.Cmd) {
|
||
option := m.setupProvider()
|
||
if option.ID == "" {
|
||
m.setup.err = "No provider option is available."
|
||
return m, nil
|
||
}
|
||
if m.setupSave == nil {
|
||
return m.exitSetupToChat()
|
||
}
|
||
|
||
name := m.setupProfileName(option)
|
||
apiKey := m.setupCredentialAPIKey(option)
|
||
// OAuth token login (e.g. xAI): the credential is the OAuth token stored under
|
||
// provider:<id>; name the profile after the provider id so the runtime resolver
|
||
// attaches the bearer, and store no API key.
|
||
if m.setup.oauthMode && !m.setupProviderDescriptor().OAuthMintsKey {
|
||
name = option.ID
|
||
apiKey = ""
|
||
}
|
||
result, err := m.setupSave(SetupSelection{
|
||
CatalogID: option.ID,
|
||
Name: name,
|
||
BaseURL: m.setupBaseURL(option),
|
||
Model: m.setupCurrentModel().ID,
|
||
APIKey: apiKey,
|
||
})
|
||
if err != nil {
|
||
m.setup.err = err.Error()
|
||
return m, nil
|
||
}
|
||
|
||
if result.ConfigPath != "" {
|
||
m.setup.configPath = result.ConfigPath
|
||
}
|
||
if result.Provider.Name != "" {
|
||
m.providerProfile = result.Provider
|
||
m.providerName = result.Provider.Name
|
||
m.modelName = result.Provider.Model
|
||
// Export ZERO_PROVIDER alongside the committed profile fields (and the
|
||
// config setupSave already persisted as active). Unlike command_center's
|
||
// switch — which commits everything only after a successful build — setup
|
||
// commits config + profile unconditionally here, so the env must match
|
||
// them unconditionally too: applyEnv makes ZERO_PROVIDER WIN over config,
|
||
// so a stale value from an earlier /model switch left in place would send
|
||
// spawned children to the OLD provider even though config now names the
|
||
// new one. That is the exact D3 gap this closes.
|
||
config.SetActiveProviderEnv(result.Provider.Name)
|
||
if m.newProvider != nil {
|
||
if provider, providerErr := m.newProvider(result.Provider); providerErr == nil {
|
||
m.provider = provider
|
||
}
|
||
}
|
||
}
|
||
|
||
return m.exitSetupToChat()
|
||
}
|
||
|
||
func (m *model) resetSetupModels() {
|
||
option := m.setupProvider()
|
||
provider := setupProviderDescriptor(option)
|
||
if setupProviderUsesTypedModel(option) {
|
||
m.setup.models = nil
|
||
m.setup.modelIndex = 0
|
||
m.setup.modelQuery = ""
|
||
m.setup.modelForID = provider.ID
|
||
m.setup.modelLoad = false
|
||
m.setup.modelErr = ""
|
||
m.setup.modelSrc = "manual"
|
||
return
|
||
}
|
||
models := providerWizardModelOptions(provider)
|
||
m.setup.models = models
|
||
m.setup.modelIndex = 0
|
||
m.setup.modelQuery = ""
|
||
m.setup.modelForID = provider.ID
|
||
m.setup.modelLoad = false
|
||
m.setup.modelErr = ""
|
||
m.setup.modelSrc = "fallback"
|
||
}
|
||
|
||
func (m model) setupModelDiscoveryCmd(gen uint64) tea.Cmd {
|
||
option := m.setupProvider()
|
||
provider := setupProviderDescriptor(option)
|
||
if provider.ID == "" || !providercatalog.RuntimeSupported(provider) {
|
||
return nil
|
||
}
|
||
if setupProviderUsesTypedModel(option) {
|
||
return nil
|
||
}
|
||
pastedKey := m.setupCredentialAPIKey(option)
|
||
// A token-login provider (e.g. xAI) keeps its bearer in the OAuth store, not as
|
||
// a pasted key; resolve it so /models is authenticated and the live list shows
|
||
// after sign-in. (OpenRouter mints a key into the credential already.)
|
||
needOAuthToken := strings.TrimSpace(pastedKey) == "" && provider.OAuth && !provider.OAuthMintsKey
|
||
baseSecrets := []string{m.setup.apiKey.Value()}
|
||
discover := m.discoverProviderModels
|
||
if discover == nil {
|
||
discover = func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||
return providermodeldiscovery.DiscoverCatalog(ctx, provider, profile, providermodeldiscovery.Options{})
|
||
}
|
||
}
|
||
providerID := provider.ID
|
||
return func() tea.Msg {
|
||
ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second)
|
||
defer cancel()
|
||
apiKey := pastedKey
|
||
if needOAuthToken {
|
||
if resolved := oauthStoredToken(ctx, providerID); resolved != "" {
|
||
apiKey = resolved
|
||
}
|
||
}
|
||
profile := providerWizardDiscoveryProfile(provider, apiKey)
|
||
secrets := append(append([]string{}, baseSecrets...), apiKey, profile.APIKey)
|
||
models, err := discover(ctx, profile)
|
||
return setupModelsDiscoveredMsg{providerID: providerID, gen: gen, redactionSecrets: secrets, models: models, err: err}
|
||
}
|
||
}
|
||
|
||
func (m model) applySetupModelsDiscovered(msg setupModelsDiscoveredMsg) model {
|
||
if !m.setup.visible || m.setup.stage != setupStageModel || m.setupProviderDescriptor().ID != msg.providerID || m.setup.modelGen != msg.gen {
|
||
return m
|
||
}
|
||
m.setup.modelLoad = false
|
||
m.setup.err = ""
|
||
if msg.err != nil {
|
||
m.setup.modelErr = redaction.RedactString(msg.err.Error(), redaction.Options{ExtraSecretValues: msg.redactionSecrets})
|
||
m.setup.modelSrc = "fallback"
|
||
if len(m.setup.models) == 0 {
|
||
m.resetSetupModels()
|
||
}
|
||
return m
|
||
}
|
||
models := providerWizardModelsFromDiscovery(msg.models)
|
||
if len(models) == 0 {
|
||
m.setup.modelErr = "models endpoint returned no model ids"
|
||
m.setup.modelSrc = "fallback"
|
||
if len(m.setup.models) == 0 {
|
||
m.resetSetupModels()
|
||
}
|
||
return m
|
||
}
|
||
currentID := m.setupCurrentModel().ID
|
||
m.setup.models = models
|
||
m.setup.modelIndex = 0
|
||
m.setup.modelSrc = providerWizardModelsSource(msg.models)
|
||
if m.setup.modelSrc == "" {
|
||
m.setup.modelSrc = "live"
|
||
}
|
||
m.setup.modelErr = ""
|
||
if currentID != "" {
|
||
for index, model := range m.setupFilteredModels() {
|
||
if model.ID == currentID {
|
||
m.setup.modelIndex = index
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func (m model) setupProviderDescriptor() providercatalog.Descriptor {
|
||
return setupProviderDescriptor(m.setupProvider())
|
||
}
|
||
|
||
func setupProviderDescriptor(option SetupProviderOption) providercatalog.Descriptor {
|
||
if descriptor, ok := providercatalog.Get(option.ID); ok {
|
||
return descriptor
|
||
}
|
||
descriptor := providercatalog.Descriptor{
|
||
ID: strings.TrimSpace(option.ID),
|
||
Name: strings.TrimSpace(option.Name),
|
||
DefaultModel: strings.TrimSpace(option.DefaultModel),
|
||
RequiresAuth: option.RequiresAuth,
|
||
Local: option.Local,
|
||
AuthEnvVars: cleanSetupEnvVar(option.EnvVar),
|
||
}
|
||
return descriptor
|
||
}
|
||
|
||
func cleanSetupEnvVar(envVar string) []string {
|
||
if envVar = strings.TrimSpace(envVar); envVar != "" {
|
||
return []string{envVar}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m model) setupCurrentModel() providerWizardModel {
|
||
if setupProviderUsesTypedModel(m.setupProvider()) {
|
||
modelID := strings.TrimSpace(m.setup.modelQuery)
|
||
if modelID == "" {
|
||
return providerWizardModel{Description: "model name required"}
|
||
}
|
||
return providerWizardModel{ID: modelID, Description: "custom model"}
|
||
}
|
||
m.ensureSetupModels()
|
||
models := m.setupFilteredModels()
|
||
if len(models) == 0 {
|
||
return providerWizardModel{Description: "no matching models"}
|
||
}
|
||
index := clamp(m.setup.modelIndex, 0, len(models)-1)
|
||
return models[index]
|
||
}
|
||
|
||
func (m *model) ensureSetupModels() {
|
||
option := m.setupProvider()
|
||
providerID := setupProviderDescriptor(option).ID
|
||
if len(m.setup.models) > 0 && m.setup.modelForID == providerID {
|
||
return
|
||
}
|
||
m.resetSetupModels()
|
||
}
|
||
|
||
func (m model) setupFilteredModels() []providerWizardModel {
|
||
query := strings.ToLower(strings.TrimSpace(m.setup.modelQuery))
|
||
if query == "" {
|
||
return append([]providerWizardModel{}, m.setup.models...)
|
||
}
|
||
models := make([]providerWizardModel, 0, len(m.setup.models))
|
||
for _, model := range m.setup.models {
|
||
if model.matches(query) {
|
||
models = append(models, model)
|
||
}
|
||
}
|
||
return models
|
||
}
|
||
|
||
func (m *model) moveSetupModel(delta int) {
|
||
if m.setup.modelLoad {
|
||
return
|
||
}
|
||
m.ensureSetupModels()
|
||
models := m.setupFilteredModels()
|
||
if len(models) == 0 {
|
||
return
|
||
}
|
||
m.setup.modelIndex = ((m.setup.modelIndex+delta)%len(models) + len(models)) % len(models)
|
||
}
|
||
|
||
func (m *model) appendSetupModelQuery(runes []rune) {
|
||
if m.setup.modelLoad {
|
||
return
|
||
}
|
||
for _, r := range runes {
|
||
if r < 32 || r == 127 {
|
||
continue
|
||
}
|
||
m.setup.modelQuery += string(r)
|
||
}
|
||
m.setup.modelIndex = 0
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m *model) deleteSetupModelQueryRune() {
|
||
if m.setup.modelLoad {
|
||
return
|
||
}
|
||
if m.setup.modelQuery == "" {
|
||
return
|
||
}
|
||
runes := []rune(m.setup.modelQuery)
|
||
m.setup.modelQuery = string(runes[:len(runes)-1])
|
||
m.setup.modelIndex = 0
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m model) exitSetupToChat() (tea.Model, tea.Cmd) {
|
||
m.setup.visible = false
|
||
m.headerPrinted = false
|
||
m.flushQueue = nil
|
||
m.printInFlight = false
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) setupCredentialInputActive() bool {
|
||
return m.setup.stage == setupStageCredentials && setupProviderAcceptsAPIKey(m.setupProvider())
|
||
}
|
||
|
||
func (m model) setupEndpointInputActive() bool {
|
||
return m.setup.stage == setupStageEndpoint && setupProviderNeedsEndpoint(m.setupProvider())
|
||
}
|
||
|
||
func (m model) setupNameInputActive() bool {
|
||
return m.setup.stage == setupStageName && setupProviderNeedsProfileName(m.setupProvider())
|
||
}
|
||
|
||
func (m model) handleSetupEndpointKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch {
|
||
case keyCtrl(msg, 'c'):
|
||
return m, tea.Quit
|
||
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
|
||
m.setup.stage = m.previousSetupStage()
|
||
m.setup.err = ""
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyEnter):
|
||
return m.advanceSetup()
|
||
case keyText(msg) != "":
|
||
m.appendSetupBaseURL(keyRunes(msg))
|
||
case keyBackspace(msg):
|
||
m.deleteSetupBaseURLRune()
|
||
case keyCtrl(msg, 'u'):
|
||
m.setup.baseURL = ""
|
||
m.setup.err = ""
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) handleSetupNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch {
|
||
case keyCtrl(msg, 'c'):
|
||
return m, tea.Quit
|
||
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
|
||
m.setup.stage = m.previousSetupStage()
|
||
m.setup.err = ""
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyEnter):
|
||
return m.advanceSetup()
|
||
case keyText(msg) != "":
|
||
m.appendSetupName(keyRunes(msg))
|
||
case keyBackspace(msg):
|
||
m.deleteSetupNameRune()
|
||
case keyCtrl(msg, 'u'):
|
||
m.setup.name = ""
|
||
m.setup.err = ""
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) handleSetupCredentialKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch {
|
||
case keyCtrl(msg, 'c'):
|
||
return m, tea.Quit
|
||
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
|
||
m.setup.stage = m.previousSetupStage()
|
||
m.setup.err = ""
|
||
return m, nil
|
||
case keyIs(msg, tea.KeyEnter):
|
||
return m.advanceSetup()
|
||
case keyIs(msg, tea.KeyUp) || keyIs(msg, tea.KeyDown):
|
||
return m, nil
|
||
}
|
||
previousAPIKey := m.setup.apiKey.Value()
|
||
var cmd tea.Cmd
|
||
m.setup.apiKey, cmd = m.setup.apiKey.Update(msg)
|
||
if m.setup.apiKey.Value() != previousAPIKey {
|
||
m.setup.modelGen++
|
||
m.resetSetupModels()
|
||
}
|
||
m.setup.err = ""
|
||
return m, cmd
|
||
}
|
||
|
||
func setupProviderAcceptsAPIKey(option SetupProviderOption) bool {
|
||
return option.RequiresAuth && !option.Local
|
||
}
|
||
|
||
func setupProviderNeedsEndpoint(option SetupProviderOption) bool {
|
||
return providerWizardNeedsEndpoint(setupProviderDescriptor(option))
|
||
}
|
||
|
||
func setupProviderNeedsProfileName(option SetupProviderOption) bool {
|
||
return setupProviderNeedsEndpoint(option)
|
||
}
|
||
|
||
func setupProviderUsesTypedModel(option SetupProviderOption) bool {
|
||
return setupProviderNeedsEndpoint(option)
|
||
}
|
||
|
||
func (m model) setupCredentialAPIKey(option SetupProviderOption) string {
|
||
if !setupProviderAcceptsAPIKey(option) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(m.setup.apiKey.Value())
|
||
}
|
||
|
||
func (m model) setupBaseURL(option SetupProviderOption) string {
|
||
if !setupProviderNeedsEndpoint(option) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(m.setup.baseURL)
|
||
}
|
||
|
||
func (m model) setupProfileName(option SetupProviderOption) string {
|
||
if !setupProviderNeedsProfileName(option) {
|
||
return ""
|
||
}
|
||
return providerWizardDisplayName(setupProviderDescriptor(option), m.setup.baseURL, m.setup.name)
|
||
}
|
||
|
||
func (m *model) appendSetupBaseURL(runes []rune) {
|
||
for _, r := range runes {
|
||
if r < 32 || r == 127 || unicode.IsSpace(r) {
|
||
continue
|
||
}
|
||
m.setup.baseURL += string(r)
|
||
}
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m *model) deleteSetupBaseURLRune() {
|
||
if m.setup.baseURL == "" {
|
||
return
|
||
}
|
||
runes := []rune(m.setup.baseURL)
|
||
m.setup.baseURL = string(runes[:len(runes)-1])
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m *model) appendSetupName(runes []rune) {
|
||
for _, r := range runes {
|
||
if r < 32 || r == 127 || unicode.IsSpace(r) {
|
||
continue
|
||
}
|
||
m.setup.name += string(r)
|
||
}
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m *model) deleteSetupNameRune() {
|
||
if m.setup.name == "" {
|
||
return
|
||
}
|
||
runes := []rune(m.setup.name)
|
||
m.setup.name = string(runes[:len(runes)-1])
|
||
m.setup.err = ""
|
||
}
|
||
|
||
func (m model) setupProvider() SetupProviderOption {
|
||
if len(m.setup.providers) == 0 {
|
||
return SetupProviderOption{}
|
||
}
|
||
index := clamp(m.setup.selected, 0, len(m.setup.providers)-1)
|
||
return m.setup.providers[index]
|
||
}
|
||
|
||
// setupErrorAffordance returns a faint one-line recovery hint tailored to the
|
||
// current setup stage, so a first-run error points the user at the inline fix
|
||
// instead of leaving them staring at a red line. Empty for stages whose footer
|
||
// already makes recovery obvious. Each hint names only keys the stage's footer
|
||
// confirms, so the guidance is always accurate.
|
||
func (m model) setupErrorAffordance() string {
|
||
switch m.setup.stage {
|
||
case setupStageEndpoint:
|
||
return "→ edit the endpoint above, then Enter to retry (← to go back)"
|
||
case setupStageCredentials:
|
||
if m.setupCredentialInputActive() {
|
||
return "→ re-enter the key above, then Enter to retry (← to go back)"
|
||
}
|
||
case setupStageName:
|
||
return "→ edit the name above, then Enter to continue (← to go back)"
|
||
case setupStageModel:
|
||
if !m.setup.modelLoad {
|
||
return "→ pick another model with ↑/↓, or type to search"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m model) setupView(width int) string {
|
||
if width <= 0 {
|
||
width = defaultStartupWidth
|
||
}
|
||
height := normalizedStartupHeight(m.height)
|
||
content := m.setupStageLines(width, height)
|
||
if m.setup.err != "" {
|
||
content = append(content, "", zeroTheme.red.Render("error: "+m.setup.err))
|
||
if hint := m.setupErrorAffordance(); hint != "" {
|
||
content = append(content, zeroTheme.faint.Render(hint))
|
||
}
|
||
}
|
||
progress := m.setupProgressText()
|
||
footer := m.setupFooter()
|
||
|
||
topGap := maxInt(0, (height-len(content)-3)/2)
|
||
bottomGap := maxInt(0, height-topGap-len(content)-2)
|
||
lines := make([]string, 0, height)
|
||
for i := 0; i < topGap; i++ {
|
||
lines = append(lines, "")
|
||
}
|
||
lines = append(lines, centerSetupLines(content, width)...)
|
||
for i := 0; i < bottomGap; i++ {
|
||
lines = append(lines, "")
|
||
}
|
||
lines = append(lines, centerLine(fitStyledLine(progress, width), width))
|
||
lines = append(lines, centerLine(fitStyledLine(footer, width), width))
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m model) setupStageLines(width int, height int) []string {
|
||
if m.setup.oauthPending {
|
||
return m.setupOAuthWaitingLines(width)
|
||
}
|
||
switch m.setup.stage {
|
||
case setupStageMethod:
|
||
return m.setupMethodLines(width)
|
||
case setupStageProvider:
|
||
return m.setupProviderLines(width, height)
|
||
case setupStageEndpoint:
|
||
return m.setupEndpointLines(width)
|
||
case setupStageName:
|
||
return m.setupNameLines(width)
|
||
case setupStageCredentials:
|
||
return m.setupCredentialLines(width)
|
||
case setupStageModel:
|
||
return m.setupModelLines(width, height)
|
||
case setupStageSafety:
|
||
return []string{
|
||
zeroTheme.ink.Bold(true).Render("Safety"),
|
||
"",
|
||
"Zero asks before running shell commands or changing files.",
|
||
"Unsafe mode stays off unless you explicitly enable it.",
|
||
"",
|
||
zeroTheme.faint.Render("Default: ask before risky work."),
|
||
}
|
||
case setupStageReady:
|
||
return m.setupReadyLines(width)
|
||
default:
|
||
return []string{
|
||
zeroTheme.accent.Render("Welcome to Zero"),
|
||
"",
|
||
zeroTheme.ink.Render("A terminal agent for changing real code."),
|
||
zeroTheme.faint.Render("Plan changes, edit with approval, run checks, and resume sessions."),
|
||
}
|
||
}
|
||
}
|
||
|
||
type setupReadyRow struct {
|
||
label string
|
||
value string
|
||
}
|
||
|
||
func (m model) setupReadyLines(width int) []string {
|
||
option := m.setupProvider()
|
||
model := m.setupCurrentModel()
|
||
providerName := displayValue(option.Name, option.ID)
|
||
if name := m.setupProfileName(option); name != "" {
|
||
providerName = name
|
||
}
|
||
|
||
rows := []setupReadyRow{{label: "provider", value: providerName}}
|
||
if endpoint := m.setupBaseURL(option); endpoint != "" {
|
||
rows = append(rows, setupReadyRow{label: "endpoint", value: endpoint})
|
||
}
|
||
rows = append(rows,
|
||
setupReadyRow{label: "model", value: displayValue(model.ID, option.DefaultModel)},
|
||
setupReadyRow{label: "credentials", value: m.setupCredentialSummary(option)},
|
||
setupReadyRow{label: "config", value: shortenPath(displayValue(m.setup.configPath, "user config"))},
|
||
)
|
||
|
||
lines := []string{
|
||
zeroTheme.ink.Bold(true).Render("Ready"),
|
||
"",
|
||
"Zero will save this setup and open chat.",
|
||
"",
|
||
}
|
||
|
||
labelWidth := 0
|
||
for _, row := range rows {
|
||
labelWidth = maxInt(labelWidth, lipgloss.Width(row.label)+1)
|
||
}
|
||
rowWidth := setupReadyRowsWidth(width, labelWidth, rows)
|
||
for _, row := range rows {
|
||
label := fmt.Sprintf("%*s", labelWidth, row.label+":")
|
||
line := " " + zeroTheme.faint.Render(label) + " " + zeroTheme.ink.Render(row.value)
|
||
lines = append(lines, padSetupLine(line, rowWidth))
|
||
}
|
||
|
||
lines = append(lines,
|
||
"",
|
||
zeroTheme.faint.Render("Later, use /provider, /doctor, or /help anytime."),
|
||
)
|
||
return lines
|
||
}
|
||
|
||
func setupReadyRowsWidth(terminalWidth int, labelWidth int, rows []setupReadyRow) int {
|
||
available := maxInt(28, minInt(terminalWidth-8, 86))
|
||
target := 0
|
||
for _, row := range rows {
|
||
target = maxInt(target, 4+labelWidth+lipgloss.Width(row.value))
|
||
}
|
||
return minInt(target, available)
|
||
}
|
||
|
||
func (m model) setupModelLines(width int, height int) []string {
|
||
if setupProviderUsesTypedModel(m.setupProvider()) {
|
||
return m.setupTypedModelLines(width)
|
||
}
|
||
m.ensureSetupModels()
|
||
if m.setup.modelLoad {
|
||
return m.setupModelLoadingLines(width)
|
||
}
|
||
rowWidth := setupModelBlockWidth(width, m.setup.models)
|
||
models := m.setupFilteredModels()
|
||
maxVisible := setupModelMaxVisible(height, len(models))
|
||
start := selectableListStart(len(models), maxVisible, m.setup.modelIndex)
|
||
lines := []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Choose a model"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+m.setupModelSearchLine(rowWidth-2), rowWidth),
|
||
}
|
||
if status := m.setupModelStatus(); status != "" {
|
||
lines = append(lines, padSetupLine(" "+zeroTheme.faint.Render(status), rowWidth))
|
||
}
|
||
lines = append(lines, blankSetupBlockLine(rowWidth))
|
||
if len(models) == 0 {
|
||
lines = append(lines, padSetupLine(" "+zeroTheme.faint.Render("No matching models"), rowWidth))
|
||
return lines
|
||
}
|
||
visibleModels := models[start : start+maxVisible]
|
||
for offset, model := range visibleModels {
|
||
lines = append(lines, m.setupModelRow(rowWidth, start+offset, model))
|
||
}
|
||
detail := setupModelSelectedDetail(m.setupCurrentModel())
|
||
lines = append(lines,
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render(detail), rowWidth),
|
||
)
|
||
return lines
|
||
}
|
||
|
||
func (m model) setupTypedModelLines(width int) []string {
|
||
option := m.setupProvider()
|
||
rowWidth := setupTextInputBlockWidth(width, option.DefaultModel)
|
||
return []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Choose a model"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.ink.Render("Enter the model ID this endpoint expects."), rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render("Examples: gpt-4.1, claude-sonnet-4-5, llama-3.3-70b"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+providerWizardInputLine("model > ", strings.TrimSpace(m.setup.modelQuery), option.DefaultModel, rowWidth-2), rowWidth),
|
||
}
|
||
}
|
||
|
||
func (m model) setupModelLoadingLines(width int) []string {
|
||
rowWidth := setupModelLoadingBlockWidth(width)
|
||
return []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Choose a model"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render("Checking available models..."), rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render("Built-in models will be used if discovery fails."), rowWidth),
|
||
}
|
||
}
|
||
|
||
func setupTextInputBlockWidth(terminalWidth int, values ...string) int {
|
||
available := maxInt(34, minInt(terminalWidth-8, 72))
|
||
target := 42
|
||
for _, value := range values {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
target = maxInt(target, lipgloss.Width(" "+value)+8)
|
||
}
|
||
}
|
||
return minInt(target, available)
|
||
}
|
||
|
||
func setupModelMaxVisible(height int, total int) int {
|
||
if total <= 0 {
|
||
return 0
|
||
}
|
||
maxVisible := height - 12
|
||
if maxVisible < 5 {
|
||
maxVisible = 5
|
||
}
|
||
if maxVisible > 18 {
|
||
maxVisible = 18
|
||
}
|
||
if maxVisible > total {
|
||
return total
|
||
}
|
||
return maxVisible
|
||
}
|
||
|
||
func setupModelLoadingBlockWidth(terminalWidth int) int {
|
||
available := maxInt(34, minInt(terminalWidth-8, 72))
|
||
target := maxInt(lipgloss.Width(" Choose a model"), lipgloss.Width(" Built-in models will be used if discovery fails."))
|
||
return minInt(maxInt(target, 42), available)
|
||
}
|
||
|
||
func setupModelBlockWidth(terminalWidth int, models []providerWizardModel) int {
|
||
available := maxInt(34, minInt(terminalWidth-8, 72))
|
||
target := lipgloss.Width(" Choose a model")
|
||
target = maxInt(target, lipgloss.Width(" search > model name..."))
|
||
for _, model := range models {
|
||
target = maxInt(target, 4+lipgloss.Width(model.displayLabel()))
|
||
if detail := setupModelSelectedDetail(model); detail != "" {
|
||
target = maxInt(target, lipgloss.Width(" "+detail))
|
||
}
|
||
}
|
||
target = maxInt(target, 42)
|
||
return minInt(target, available)
|
||
}
|
||
|
||
func (m model) setupModelSearchLine(width int) string {
|
||
query := strings.TrimSpace(m.setup.modelQuery)
|
||
prompt := zeroTheme.userPrompt.Render("search > ")
|
||
cursor := zeroTheme.accent.Render("▌")
|
||
if query == "" {
|
||
return fitStyledLine(prompt+cursor+zeroTheme.faint.Render("model name..."), width)
|
||
}
|
||
return fitStyledLine(prompt+zeroTheme.ink.Render(query)+cursor, width)
|
||
}
|
||
|
||
func (m model) setupModelStatus() string {
|
||
if m.setup.modelLoad {
|
||
return "Refreshing available models"
|
||
}
|
||
if m.setup.modelErr != "" {
|
||
return "Using built-in model list"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m model) setupModelRow(width int, index int, model providerWizardModel) string {
|
||
selected := index == m.setup.modelIndex
|
||
marker := " "
|
||
style := zeroTheme.ink
|
||
if selected {
|
||
marker = "❯ "
|
||
style = zeroTheme.accent.Bold(true)
|
||
}
|
||
left := marker + style.Render(model.displayLabel())
|
||
return padSetupLine(left, width)
|
||
}
|
||
|
||
func setupModelSelectedDetail(model providerWizardModel) string {
|
||
parts := []string{}
|
||
if secondary := strings.TrimSpace(model.secondaryText()); secondary != "" && !providerWizardGenericModelDescription(secondary) {
|
||
parts = append(parts, secondary)
|
||
}
|
||
if meta := strings.TrimSpace(model.Meta); meta != "" {
|
||
parts = append(parts, meta)
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
func (m model) setupMethodLines(width int) []string {
|
||
options := m.setupMethodOptions()
|
||
rowWidth := setupMethodBlockWidth(width, options)
|
||
idx := clamp(m.setup.selectedMethod, 0, maxInt(0, len(options)-1))
|
||
lines := []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("How do you want to connect?"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
}
|
||
for index, option := range options {
|
||
marker := " "
|
||
style := zeroTheme.ink
|
||
if index == idx {
|
||
marker = "❯ "
|
||
style = zeroTheme.accent.Bold(true)
|
||
}
|
||
lines = append(lines, padSetupLine(marker+style.Render(option.label), rowWidth))
|
||
lines = append(lines, padSetupLine(" "+zeroTheme.faint.Render(option.subtitle), rowWidth))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func setupMethodBlockWidth(terminalWidth int, options []providerWizardMethodOption) int {
|
||
available := maxInt(34, minInt(terminalWidth-8, 86))
|
||
target := lipgloss.Width(" How do you want to connect?")
|
||
for _, option := range options {
|
||
target = maxInt(target, 2+lipgloss.Width(option.label))
|
||
target = maxInt(target, 4+lipgloss.Width(option.subtitle))
|
||
}
|
||
return minInt(maxInt(target, 42), available)
|
||
}
|
||
|
||
func (m model) setupOAuthWaitingLines(width int) []string {
|
||
provider := m.setupProviderDescriptor()
|
||
name := displayValue(provider.Name, provider.ID)
|
||
var lines []string
|
||
if m.setup.oauthDevice {
|
||
lines = []string{zeroTheme.ink.Bold(true).Render("Device-code sign-in for " + name), ""}
|
||
if m.setup.deviceUserCode == "" {
|
||
lines = append(lines, zeroTheme.faint.Render("Requesting a device code..."))
|
||
} else {
|
||
lines = append(lines,
|
||
"1. On any device, visit: "+zeroTheme.accent.Render(m.setup.deviceVerificationURI),
|
||
"2. Enter the code: "+zeroTheme.accent.Bold(true).Render(m.setup.deviceUserCode),
|
||
"",
|
||
zeroTheme.faint.Render("Waiting for authorization..."),
|
||
)
|
||
}
|
||
} else {
|
||
lines = []string{
|
||
zeroTheme.ink.Bold(true).Render("Signing in with " + name),
|
||
"",
|
||
"Opening your browser — approve there, then return here.",
|
||
zeroTheme.faint.Render("Waiting for authorization..."),
|
||
"",
|
||
zeroTheme.faint.Render("If your browser didn't open, run: " + providerWizardOAuthCLIHint(provider)),
|
||
}
|
||
}
|
||
if m.setup.oauthErr != "" {
|
||
lines = append(lines, "", zeroTheme.red.Render("Sign-in failed: "+m.setup.oauthErr))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m model) setupProviderLines(width int, height int) []string {
|
||
rowWidth := setupProviderBlockWidth(width, m.setup.providers)
|
||
maxVisible := setupProviderMaxVisible(height, len(m.setup.providers))
|
||
start := selectableListStart(len(m.setup.providers), maxVisible, m.setup.selected)
|
||
visibleProviders := m.setup.providers[start : start+maxVisible]
|
||
lines := []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Choose a provider"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
}
|
||
for index, option := range visibleProviders {
|
||
absoluteIndex := start + index
|
||
marker := " "
|
||
style := zeroTheme.ink
|
||
if absoluteIndex == m.setup.selected {
|
||
marker = "❯ "
|
||
style = zeroTheme.accent.Bold(true)
|
||
}
|
||
label := displayValue(option.Name, option.ID)
|
||
if option.Recommended {
|
||
label = "★ " + label
|
||
}
|
||
line := marker + style.Render(label)
|
||
if option.Recommended {
|
||
line += zeroTheme.faint.Render(" (recommended)")
|
||
}
|
||
lines = append(lines, padSetupLine(line, rowWidth))
|
||
}
|
||
// A failed OAuth login drops back here with oauthPending cleared, so the
|
||
// waiting screen (which owns the error) is gone — surface it on the list the
|
||
// user lands on so the failure isn't silent and they can retry.
|
||
if m.setup.oauthMode && m.setup.oauthErr != "" {
|
||
lines = append(lines,
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.red.Render("Sign-in failed: "+m.setup.oauthErr), rowWidth),
|
||
)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m model) setupEndpointLines(width int) []string {
|
||
option := m.setupProvider()
|
||
provider := setupProviderDescriptor(option)
|
||
rowWidth := setupTextInputBlockWidth(width, providerWizardEndpointPlaceholder(provider), m.setup.baseURL)
|
||
return []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Endpoint URL"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.ink.Render("Enter the API base URL for "+displayValue(option.Name, option.ID)+"."), rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render(providerWizardEndpointHint(provider)), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+providerWizardInputLine("url > ", strings.TrimSpace(m.setup.baseURL), providerWizardEndpointPlaceholder(provider), rowWidth-2), rowWidth),
|
||
}
|
||
}
|
||
|
||
func (m model) setupNameLines(width int) []string {
|
||
option := m.setupProvider()
|
||
provider := setupProviderDescriptor(option)
|
||
name := providerWizardDisplayName(provider, m.setup.baseURL, m.setup.name)
|
||
rowWidth := setupTextInputBlockWidth(width, name, m.setup.name)
|
||
return []string{
|
||
padSetupLine(" "+zeroTheme.ink.Bold(true).Render("Provider name"), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+zeroTheme.ink.Render("Choose the short label shown in Zero."), rowWidth),
|
||
padSetupLine(" "+zeroTheme.faint.Render("Leave blank to use "+name+"."), rowWidth),
|
||
blankSetupBlockLine(rowWidth),
|
||
padSetupLine(" "+providerWizardInputLine("name > ", strings.TrimSpace(m.setup.name), name, rowWidth-2), rowWidth),
|
||
}
|
||
}
|
||
|
||
func setupProviderMaxVisible(height int, total int) int {
|
||
if total <= 0 {
|
||
return 0
|
||
}
|
||
maxVisible := height - 8
|
||
if maxVisible < 6 {
|
||
maxVisible = 6
|
||
}
|
||
if maxVisible > total {
|
||
return total
|
||
}
|
||
return maxVisible
|
||
}
|
||
|
||
func setupProviderBlockWidth(terminalWidth int, providers []SetupProviderOption) int {
|
||
available := maxInt(24, minInt(terminalWidth-8, 44))
|
||
target := maxInt(lipgloss.Width(" 2/6"), lipgloss.Width(" Choose a provider"))
|
||
for _, provider := range providers {
|
||
target = maxInt(target, 2+lipgloss.Width(displayValue(provider.Name, provider.ID)))
|
||
}
|
||
target = maxInt(target, 32)
|
||
return minInt(target, available)
|
||
}
|
||
|
||
func blankSetupBlockLine(width int) string {
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
return strings.Repeat(" ", width)
|
||
}
|
||
|
||
func (m model) setupCredentialLines(width int) []string {
|
||
option := m.setupProvider()
|
||
lines := []string{
|
||
zeroTheme.ink.Bold(true).Render("Credentials"),
|
||
"",
|
||
}
|
||
if option.Local || !option.RequiresAuth {
|
||
lines = append(lines,
|
||
displayValue(option.Name, option.ID)+" does not need an API key.",
|
||
"Start the local server before sending a prompt.",
|
||
)
|
||
return lines
|
||
}
|
||
envVar := displayValue(option.EnvVar, "the provider API key env var")
|
||
lines = append(lines,
|
||
"Paste your "+displayValue(option.Name, option.ID)+" API key",
|
||
"or leave blank to use "+envVar+" from your shell.",
|
||
"",
|
||
m.setupAPIKeyInputLine(width),
|
||
"",
|
||
zeroTheme.faint.Render("Saved keys stay in your user config."),
|
||
zeroTheme.faint.Render("Blank uses "+envVar+" from your shell."),
|
||
)
|
||
return lines
|
||
}
|
||
|
||
func (m model) setupAPIKeyInputLine(width int) string {
|
||
input := m.setup.apiKey
|
||
if strings.TrimSpace(input.Value()) == "" {
|
||
return zeroTheme.faint.Render(input.Placeholder)
|
||
}
|
||
contentWidth := lipgloss.Width(input.Value())
|
||
if contentWidth == 0 {
|
||
contentWidth = lipgloss.Width(input.Placeholder)
|
||
}
|
||
input.SetWidth(minInt(maxInt(contentWidth, 1), maxInt(1, width-lipgloss.Width(input.Prompt))))
|
||
return input.View()
|
||
}
|
||
|
||
func (m model) setupCredentialSummary(option SetupProviderOption) string {
|
||
// An OAuth token-login provider (e.g. xAI) is authenticated by a stored,
|
||
// refreshable token, not an API key — don't advertise an env var for it on the
|
||
// Ready screen. (Key-minting OAuth like OpenRouter still ends up as a saved key.)
|
||
if m.setup.oauthMode {
|
||
if d, ok := providercatalog.Get(option.ID); ok && d.OAuth && !d.OAuthMintsKey {
|
||
return "OAuth token"
|
||
}
|
||
}
|
||
if !setupProviderAcceptsAPIKey(option) {
|
||
return "not required"
|
||
}
|
||
if m.setupCredentialAPIKey(option) != "" {
|
||
return "saved API key"
|
||
}
|
||
return "env var " + displayValue(option.EnvVar, "provider API key")
|
||
}
|
||
|
||
func (m model) setupFooter() string {
|
||
if m.setup.oauthPending {
|
||
return zeroTheme.faint.Render("Esc cancel")
|
||
}
|
||
switch m.setup.stage {
|
||
case setupStageMethod:
|
||
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue q quit")
|
||
case setupStageReady:
|
||
return zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" to save and start chat")
|
||
case setupStageEndpoint:
|
||
return zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
|
||
case setupStageName:
|
||
return zeroTheme.faint.Render("name optional ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
|
||
case setupStageCredentials:
|
||
if m.setupCredentialInputActive() {
|
||
return zeroTheme.faint.Render("paste key optional ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue left back")
|
||
}
|
||
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to continue")
|
||
case setupStageProvider:
|
||
if m.setup.oauthMode && m.setupProviderDescriptor().OAuthDeviceFlow {
|
||
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" sign in ") + zeroTheme.accent.Render("d") + zeroTheme.faint.Render(" device code q quit")
|
||
}
|
||
return zeroTheme.faint.Render("↑/↓ choose ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue q quit")
|
||
case setupStageModel:
|
||
if m.setup.modelLoad {
|
||
return zeroTheme.faint.Render("checking models...")
|
||
}
|
||
return zeroTheme.faint.Render("↑/↓ choose type search ") + zeroTheme.accent.Render("Enter") + zeroTheme.faint.Render(" continue")
|
||
case setupStageWelcome:
|
||
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to set up Zero")
|
||
default:
|
||
return zeroTheme.accent.Render("Space") + zeroTheme.faint.Render(" to continue")
|
||
}
|
||
}
|
||
|
||
func centerSetupLines(lines []string, width int) []string {
|
||
fitted := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
fitted = append(fitted, centerLine(fitStyledLine(line, width), width))
|
||
}
|
||
return fitted
|
||
}
|
||
|
||
func (m model) setupProgressText() string {
|
||
stages := m.setupStages()
|
||
position := 0
|
||
for index, stage := range stages {
|
||
if stage == m.setup.stage {
|
||
position = index
|
||
break
|
||
}
|
||
}
|
||
return zeroTheme.faint.Render(fmt.Sprintf("%d/%d", position+1, len(stages)))
|
||
}
|
||
|
||
func padSetupLine(line string, width int) string {
|
||
if width <= 0 {
|
||
return line
|
||
}
|
||
if pad := width - lipgloss.Width(line); pad > 0 {
|
||
return line + strings.Repeat(" ", pad)
|
||
}
|
||
return fitStyledLine(line, width)
|
||
}
|