Compare commits

..
Author SHA1 Message Date
Gnanam a766163401 fix: pre-push review findings on the exec_command buffer-cap fix
Ran an adversarial review before opening the PR. It found the truncation
notice added alongside the buffer cap didn't actually work: prefixing
execOutputTruncatedMessage into drainString()'s text meant the notice
always sat ~2MiB before the end of the combined output, but the tool's
own downstream truncateExecOutput() keeps at most a 400KB tail (even at
the maximum allowed max_output_tokens) — so the notice was reliably
swallowed, or chopped into an unreadable fragment at smaller budgets.
Verified with direct reproductions against the real functions.

Fixed by moving the signal out of the text stream entirely:
- execOutputBuffer gains consumeTruncated() (read+reset, called once per
  collect()) and peekTruncated() (read-only, for status views) instead of
  injecting a marker into the drained bytes.
- collect() now returns (string, bool); both call sites thread the bool
  through to execToolResult, which sets meta["output_buffer_truncated"]
  and appends the notice to the response body *after* truncateExecOutput
  runs, so it can never be re-truncated or land in a discarded region.
- Also flagged: a session torn down without ever being polled again after
  truncation loses the signal entirely, since nothing drains/logs it on
  teardown. Full audit-trail logging felt like overkill for a soft
  transparency signal on top of a hard memory-safety fix, but exposed
  peekTruncated() on ExecSessionSnapshot and surfaced it in the /ps row
  (background_terminals.go) so it's visible any time a still-running
  session is checked — covers the common case (the review's own scenario:
  a long-lived dev server nobody's polling but that's still alive) even
  though a session reaped before ever being looked at is still a gap.

New tests: reworked TestExecOutputBufferCapsUndrainedData for the
consume/peek API, and TestExecToolResultSurfacesBufferTruncationOutsideByteBudget
reproducing the review's exact failure case (max_output_tokens=1) and
proving the notice now survives it.
2026-07-01 14:54:20 +05:30
Gnanam 16ce70b7a9 fix: cap unbounded exec_command output buffer to prevent OOM kill
execOutputBuffer.data accumulated every byte a backgrounded exec_command
session wrote, with no size cap, unlike buffer.recent which is correctly
bounded to recentExecOutputBytes for the /ps preview. drainString() only
clears data when the agent actually polls that session again — so a
long-lived background session nobody polls (e.g. a dev server left running
after the run that started it was cancelled) grows this buffer forever as
long as the process keeps writing, with no ceiling.

Traced this directly to a real crash: a session that had been running for
~5.5 hours (an earlier turn invoking swarm agents got cancelled mid-flight,
per that session's own event log) accumulated to ~42.7GB of compressed
memory, at which point macOS's memorystatus killed the process outright
("killing zero due to low-swap").

Fixed by capping data at maxExecOutputBufferBytes (2MiB), trimming from the
front like recent already does, and surfacing a one-time truncation notice
on the next drain so data loss is visible rather than silent.
2026-07-01 14:21:42 +05:30
69 changed files with 464 additions and 3416 deletions

No files matched your search

+1 -8
View File
@@ -11,14 +11,7 @@ tagged. Until then, source builds report the version `dev`.
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+3 -3
View File
@@ -254,9 +254,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
+21 -36
View File
@@ -210,29 +210,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
// the only output whose re-stream on a stall retry would visibly DUPLICATE
// answer text the user already read. Transient working indicators
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
// counted: a turn that streamed only those then stalled — the common
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
// the TUI resets the tool-call preview on the next tool-call-start and a
// stalled turn is never appended to `messages` (it returns on the error
// before the append), so the retry re-sends clean context with no
// conversation-state duplication.
forwardedVisibleText := false
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
// never re-streams on top of output the user already saw — even across the
// reactive-compaction retry below, which overwrites `collected`.
forwardedAnything := false
mark := func() { forwardedAnything = true }
// OnUsage is telemetry, not visible output, so it does NOT mark
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
// tokens) then stalls with no content must still be safely retried. The gate
// only guards against re-streaming output the user actually SAW.
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
@@ -286,28 +284,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
// large write_file) then froze mid-arguments — the common gpt-5.x /
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
// (a turn with collected.Error returns before dispatch below) and NEVER
// appended to `messages`, so a retry re-issues clean context; the only
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
// A stream idle/stall timeout with NO output forwarded yet this turn can be
// safely re-issued on a fresh connection — nothing was shown, so there's no
// duplication (this recovers the macOS stale-pooled-connection hang when it
// slips past the transport's response-header timeout). A turn that already
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
// falls through to the error return below. Capped + exponential backoff.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
-15
View File
@@ -40,21 +40,6 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
}
}
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
// stream that connected and produced only transient output, then went silent).
// Distinct wording from reconnectNoticeFor's "connection lost" because the
// connection was fine — the model stalled. Surfaced through OnReasoning, the
// non-content channel, so it never folds into the answer text. Nil when there
// is no reasoning sink (the retry still happens silently).
func stallRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
+19 -65
View File
@@ -10,25 +10,19 @@ import (
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". The
// pre-stall emission simulates different partial-output shapes:
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
// (re-issuing would duplicate visible answer text).
// - reasoningText: transient reasoning preview — IS retried.
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
// "froze mid-write_file" case — IS retried (the incomplete call is never
// executed or committed).
// timeout error for the first stallBefore calls, then succeeds with "done". When
// partialText is set it emits that text BEFORE the stall, simulating a stall after
// partial output (which must NOT be retried — re-issuing would duplicate it).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
partialToolCall string
calls int32
stallBefore int32
partialText string
reasoningText string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 5)
ch := make(chan zeroruntime.StreamEvent, 3)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
@@ -36,16 +30,9 @@ func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.Comple
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
if p.partialToolCall != "" {
// A tool call that starts and streams a partial argument fragment but
// never gets StreamEventToolCallEnd, then the stream errors — exactly
// the gpt-5.x "began the big write_file then froze" shape.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 6m (the model produced nothing)",
Error: "provider stream error: no output for 10m (the model produced nothing)",
}
close(ch)
return ch, nil
@@ -85,54 +72,21 @@ func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
}
}
// A stall after only transient REASONING was forwarded (no prose text, no
// completed tool call) IS retried: reasoning is a "thinking" preview, not
// committed answer text, so re-streaming it on a clearly-signalled retry is
// acceptable and recovers the common "reasoned then froze" stall. Only
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
// here, so this exercises the transient-vs-prose gate specifically).
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
// retried: the user already saw the reasoning, so re-issuing with callbacks
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
// the turn-level forwarded-output gate specifically, not the text check.
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
result, err := Run(context.Background(), "go", p, Options{
_, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err != nil {
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
if err == nil {
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
// froze" case. The incomplete call is never executed (a turn returns on the
// stall error before dispatch) and never committed to history, so re-issuing is
// safe and recovers to success. This is the primary scenario this change fixes.
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
starts := 0
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
})
if err != nil {
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
}
}
+2 -6
View File
@@ -455,15 +455,11 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
// The timeout is longer than materialization of the small fixture even on
// slower Windows CI runners, so the agent is reached before it blocks until
// the deadline fires. 10s wasn't generous enough in practice — it failed
// on an otherwise-unrelated PR merge (materialization alone consumed the
// full budget on a contended Windows runner, so the agent was never
// reached); widened for headroom, since the assertions below still bound
// worst-case test time via that same deadline, not this constant.
// the deadline fires.
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
})
if !agentReached {
+2 -5
View File
@@ -43,11 +43,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
+28 -80
View File
@@ -43,17 +43,12 @@ import (
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
@@ -104,10 +99,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -124,14 +118,9 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
// bound must describe the same login (the key is passed on to the Codex
// account-header resolver so it never re-selects independently).
resolver, loginKey := oauthLoginForProfile(profile)
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: resolver,
OAuthLoginKey: loginKey,
OAuthResolver: oauthResolverForProfile(profile),
})
},
probeProviderHealth: providerhealth.Probe,
@@ -228,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -504,17 +493,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.now == nil {
deps.now = defaults.now
}
// Wrap newProvider ONCE, after all defaults are filled, so every surface that
// builds the runtime provider gets the credential-store key applied. The
// resolver is pure and leaves apiKeyStored profiles keyless; an unwrapped
// build sends unauthenticated requests. config.ApplyStoredAPIKey is a no-op
// when the key is already set or the profile isn't stored-key, so this is
// safe and idempotent for every profile kind and every injected newProvider.
baseNewProvider := deps.newProvider
userConfigPath := deps.userConfigPath
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
return deps
}
@@ -663,7 +641,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -742,50 +719,20 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
}
}
// buildProvider constructs the run's provider at STARTUP — it is called only from
// the two launch paths (interactive TUI and headless exec), never from mid-run
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
// (and every other newProvider caller) needs no per-site key handling.
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return nil, err
}
// Pin spawned children (sub-agents / swarm members inherit the environment) to
// THIS run's provider from launch, not only after an in-session switch. Without
// the launch-time export a child re-resolves config.json at spawn time, so a
// provider switch persisted by ANOTHER zero process mid-session would silently
// move new children onto a different provider (and different credentials) than
// the parent is running. Runtime switches (/model, /provider, wizard,
// onboarding) re-export on commit, keeping the pin current. Injected via deps
// (nil in tests) because it mutates the process environment.
if deps.exportActiveProvider != nil {
deps.exportActiveProvider(resolved.Provider.Name)
}
return provider, nil
}
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
// when the resolver left it empty (the resolver is pure — no I/O — so an
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
// providers and when nothing is stored. This is applied once, centrally, by the
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
// and any future caller) is covered without a per-site invariant to forget.
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
if userConfigPath == nil {
return profile
}
if path, perr := userConfigPath(); perr == nil {
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return profile
return deps.newProvider(profile)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -1086,10 +1033,9 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1097,7 +1043,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1107,10 +1053,12 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
+1 -1
View File
@@ -1397,7 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
-3
View File
@@ -283,9 +283,6 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// carries no token material. A real browser launch is intentionally not
// performed (the sandbox/headless contexts make printing the safer default).
OpenBrowser: func(string) error { return nil },
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
AllowPresets: true,
})
}
-180
View File
@@ -1,180 +0,0 @@
package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// seedStoredProviderKey writes key into a credential store rooted beside the
// returned config path, and returns deps fields wired at that path — the same
// layout applyStoredProviderKey resolves through in production.
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
t.Helper()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
dir := t.TempDir()
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("ProviderKeyStoreAt: %v", err)
}
if err := store.Set(providerName, key); err != nil {
t.Fatalf("seed stored key: %v", err)
}
return filepath.Join(dir, "config.json")
}
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
ucp := func() (string, error) { return configPath, nil }
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
if stored.APIKey != "sk-stored-test" {
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
}
// A profile that did NOT opt into stored-key auth must be left unchanged —
// a stale store entry must not silently reactivate credentials.
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
if plain.APIKey != "" {
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
}
}
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
// escalation switcher) gets the stored key applied. Passing the raw resolved
// profile previously sent unauthenticated requests for apiKeyStored profiles,
// the default onboarding outcome. Testing the wrap covers all those surfaces.
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
captured = profile
return nil, nil // only the captured profile matters here
},
})
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
t.Fatalf("wrapped newProvider: %v", err)
}
if captured.APIKey != "sk-stored-wrap" {
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
// this, a provider switch persisted by another zero process mid-session moves
// new children onto a different provider (and credentials) than the parent.
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
// process env). Inject the real one here to assert the actual env effect.
deps := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, nil
},
}
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
if _, err := buildProvider(resolved, deps); err != nil {
t.Fatalf("buildProvider: %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
}
// A FAILED build must not move the pin: the process never committed to the
// new provider, so children must keep resolving whatever was in effect.
t.Setenv(config.ActiveProviderEnv, "still-current")
failing := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, errors.New("boom")
},
}
if _, err := buildProvider(resolved, failing); err == nil {
t.Fatal("expected build error")
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
}
// No provider profile at all → no provider, no pin change.
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
t.Fatalf("buildProvider(empty): %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
}
}
// A mid-run --allow-escalation model switch previously rebuilt the provider
// from the PURE resolved profile (the stored key lives only in buildProvider's
// local copy), so the escalated provider was keyless and the next completion
// call 401'd mid-run. Every profile handed to newProvider on this path must
// carry the stored key.
func TestRunExecEscalationSwitchKeepsStoredKey(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
var builtProfiles []config.ProviderProfile
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_stored_key_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return configPath, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.Provider.APIKeyStored = true // key lives ONLY in the credstore
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builtProfiles = append(builtProfiles, profile)
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
}
if len(builtProfiles) < 2 {
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
}
for i, profile := range builtProfiles {
if profile.APIKey != "sk-stored-escalation" {
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
}
}
}
-3
View File
@@ -365,9 +365,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
// the escalated provider is authenticated even though resolved.Provider
// is the pure profile — no per-site key handling here.
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
-86
View File
@@ -24,7 +24,6 @@ func TestProviderHasOAuthLogin(t *testing.T) {
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
@@ -46,88 +45,3 @@ func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
// A profile renamed away from its catalog ID must still get a runtime OAuth
// resolver when the login is stored under the catalog name — and a CASE-VARIANT
// profile name must not drop the catalog-ID candidate (the store is
// case-sensitive; a case-insensitive dedupe would swallow it).
func TestOAuthResolverForProfileFallsBackToCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// A renamed profile resolves the catalog-ID login, and the returned key MUST be
// that login's key — the same key the Codex account-header resolver reads from,
// so bearer and account can never come from different logins.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("renamed profile with a catalog-ID login must get a resolver (unauthenticated-children regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("bound key = %q, want the chatgpt login key (bearer/account must share it)", key)
}
// Name differs from catalog ID only in CASE: the exact-case store key is
// "provider:chatgpt", so "chatgpt" must survive as a distinct candidate.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("case-variant profile name must still resolve the catalog-ID login (case-insensitive dedupe regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("case-variant bound key = %q, want the chatgpt login key", key)
}
if resolver, _ := oauthLoginForProfile(config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("catalog-named profile must get a resolver")
}
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "openai", CatalogID: "openai"}); resolver != nil || key != "" {
t.Fatalf("a profile with no stored login must get no resolver and an empty key, got key=%q", key)
}
}
// The catalog-ID fallback must NOT reach a sibling profile's OAuth login when
// this profile has its own key — withBearer would erase the key and silently
// bill the model call under the sibling's account (a real cross-profile
// override the fallback introduced). A profile WITH its own resolved key gets no
// resolver even when a catalog-shared login exists.
func TestOAuthResolverForProfileDoesNotOverrideExplicitKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
// A personal login stored under the catalog name.
if err := store.Save(oauth.ProviderKey("anthropic"), oauth.Token{AccessToken: "personal-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// Work profile: same catalog, its OWN resolved API key, deliberately NOT the
// personal OAuth account. It must keep using its key (no resolver attached).
work := config.ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work-key"}
if resolver, key := oauthLoginForProfile(work); resolver != nil || key != "" {
t.Fatalf("a profile with its own API key must not get a catalog-shared OAuth resolver (cross-profile override), got key=%q", key)
}
// A raw auth-header credential must be protected the same way.
header := config.ProviderProfile{Name: "anthropic-hdr", CatalogID: "anthropic", AuthHeaderValue: "Bearer byo"}
if resolver, _ := oauthLoginForProfile(header); resolver != nil {
t.Fatal("a profile with its own auth header must not get a catalog-shared OAuth resolver")
}
// A same-NAME login must not override an explicit key either (the name
// candidate is gated on the profile being keyless, not just the catalog one).
if err := store.Save(oauth.ProviderKey("anthropic-work"), oauth.Token{AccessToken: "name-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed name token: %v", err)
}
if resolver, _ := oauthLoginForProfile(work); resolver != nil {
t.Fatal("a profile with its own API key must not get an OAuth resolver even from a same-name login")
}
// Sanity: a keyless sibling on the same catalog DOES resolve the shared login.
keyless := config.ProviderProfile{Name: "anthropic-oauth", CatalogID: "anthropic"}
if resolver, key := oauthLoginForProfile(keyless); resolver == nil {
t.Fatal("a keyless profile should still resolve the catalog-shared login")
} else if key != oauth.ProviderKey("anthropic") {
t.Fatalf("keyless sibling bound key = %q, want the catalog login key", key)
}
}
+26 -32
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
@@ -11,48 +12,42 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
// returns both a TokenResolver that authenticates model calls with it and the
// credential-store key it bound to. It returns (nil, "") when no login exists —
// keeping API-key users free of any per-request store lookups, since the resolver
// is only attached when a login is present at construction time.
//
// The returned key is the single source of truth for "which login is this
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
// Codex chatgpt-account-id header reads its account from the exact login that
// issued the bearer token, instead of doing a second, independent lookup that
// could select a different login (a backend-rejected mismatch).
//
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
// the profile having no own configured credential) come from the shared
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
return nil
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
return nil
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
@@ -69,5 +64,4 @@ func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResol
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
+1 -1
View File
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+3 -7
View File
@@ -314,14 +314,10 @@ func setupRequired(resolved config.ResolvedConfig) bool {
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
// runtime" resolver can never diverge. This is only consulted for a profile with
// no usable API-key credential (setupRequired returns early otherwise), where
// the candidate set is permissive (profile name + catalog ID).
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
-57
View File
@@ -155,63 +155,6 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error)
return migrated, nil
}
// HasConfiguredCredential reports whether the profile is set up to authenticate
// with its own API key (inline, stored, or a raw auth header). It is the single
// definition of "this profile is key-authed", shared by OAuthLoginCandidates and
// the cli/tui setup surfaces so they never disagree about whether a profile is
// keyed. APIKeyEnv is deliberately NOT included: an env var may be unset at
// runtime while the profile actually relies on an OAuth login, so an
// env-configured-but-empty profile must still be allowed to resolve a token.
func (profile ProviderProfile) HasConfiguredCredential() bool {
return strings.TrimSpace(profile.APIKey) != "" ||
profile.APIKeyStored ||
strings.TrimSpace(profile.AuthHeaderValue) != ""
}
// OAuthLoginCandidates returns the login names to try, in order, when resolving
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
// account-header resolver, and the onboarding presence check so all three agree.
//
// It returns NO candidates for a profile that carries its own configured
// credential: attaching an OAuth resolver to such a profile makes withBearer
// erase the key the instant a token resolves, silently routing/billing the call
// through the OAuth account instead of the intended key (the cross-profile
// override the review caught — and, via the profile name, a same-name login
// overriding an explicit key). A stored-key profile that reaches this keyless
// (e.g. a transiently unreadable keyring) is likewise treated as key-auth, so it
// fails with a clear missing-credential error rather than silently borrowing an
// unrelated OAuth login. APIKeyEnv is intentionally not part of that gate (an
// env var may be unset while the profile relies on OAuth).
//
// For a keyless profile the profile name comes first (a login under your own
// profile name is unambiguously yours), then the catalog ID as a FALLBACK (so a
// profile renamed away from its catalog ID — e.g. {name:"codex",
// catalogID:"chatgpt"} — still finds a `zero auth login chatgpt` token).
// Candidates are trimmed, blank-skipped, and de-duplicated CASE-SENSITIVELY — the
// OAuth token store is a case-sensitive map, so "ChatGPT" and "chatgpt" are
// distinct keys and both must survive.
func (profile ProviderProfile) OAuthLoginCandidates() []string {
if profile.HasConfiguredCredential() {
return nil
}
var names []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
for _, existing := range names {
if existing == s { // case-sensitive: the store lookup is case-sensitive
return
}
}
names = append(names, s)
}
add(profile.Name)
add(profile.CatalogID)
return names
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
-66
View File
@@ -172,69 +172,3 @@ func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
// fallback ONLY when the profile has no effective own credential, and dedupes
// case-sensitively (the OAuth store is a case-sensitive map).
func TestOAuthLoginCandidates(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
want []string
}{
{
name: "renamed keyless profile falls back to catalog id",
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
want: []string{"codex", "chatgpt"},
},
{
name: "case-variant name keeps distinct catalog-id candidate",
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
want: []string{"ChatGPT", "chatgpt"},
},
{
name: "exact-duplicate name and catalog id collapse",
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
want: []string{"chatgpt"},
},
{
// A configured key must block ALL candidates (name included): a login
// under the profile's own name would otherwise erase the key too.
name: "own inline key blocks every candidate",
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
want: nil,
},
{
name: "own auth header blocks every candidate",
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
want: nil,
},
{
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
// transiently unreadable keyring): don't silently borrow an OAuth login.
name: "stored-key profile blocks every candidate",
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
want: nil,
},
{
// APIKeyEnv is NOT a configured credential for gating: the env var may be
// unset while the profile relies on an OAuth login, so candidates stand.
name: "env-only profile still yields candidates",
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
want: []string{"xai"},
},
{
name: "empty profile yields no candidates",
profile: ProviderProfile{},
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.profile.OAuthLoginCandidates()
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
}
})
}
}
-3
View File
@@ -195,9 +195,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
-4
View File
@@ -97,10 +97,6 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
-22
View File
@@ -154,28 +154,6 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
-33
View File
@@ -272,39 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+21 -39
View File
@@ -1,11 +1,11 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -393,43 +393,28 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
var incoming bytes.Buffer
incomingWriter := newMessageWriter(&incoming)
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 99,
Error: &rpcError{Code: -32000, Message: "wrong response"},
}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 1,
Result: mustRaw(map[string]any{"value": "matched"}),
}); err != nil {
t.Fatal(err)
}
var outgoing bytes.Buffer
client := &Client{
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
reader: newMessageReader(&incoming),
writer: newMessageWriter(&outgoing),
nextID: 1,
}
var result struct {
@@ -441,9 +426,6 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
+5 -16
View File
@@ -44,17 +44,10 @@ type Manager struct {
// ManagerOptions configures a Manager.
type ManagerOptions struct {
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
// AllowPresets opts this manager into the baked-in OAuth presets without the
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
// and CLI login (and the runtime token refresh) for a provider the user chose
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
// flag onto Env (or the process environment when Env is nil), preserving any
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
AllowPresets bool
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
Now func() time.Time
RefreshBuffer time.Duration
Out io.Writer
@@ -90,13 +83,9 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
if open == nil {
open = func(string) error { return nil }
}
env := opts.Env
if opts.AllowPresets {
env = envWithPresetsAllowed(env)
}
return &Manager{
store: opts.Store, registry: registry, client: client,
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
}, nil
}
-24
View File
@@ -52,30 +52,6 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
return m
}
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
// preset provider resolves; without it (and no Env) the env stays nil so callers
// and tests remain hermetic.
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
}
m2, err := NewManager(ManagerOptions{Store: store})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m2.env != nil {
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
}
}
func TestManagerLoginLoopback(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
+1 -28
View File
@@ -1,33 +1,6 @@
package oauth
import (
"os"
"strings"
)
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
// login for a well-known public client (e.g. xAI) can use the preset without the
// operator setting the flag themselves. It copies base — or snapshots the process
// environment when base is nil — because envValue treats a non-nil map as hermetic
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
func envWithPresetsAllowed(base map[string]string) map[string]string {
env := make(map[string]string, len(base)+1)
if base == nil {
for _, kv := range os.Environ() {
if eq := strings.IndexByte(kv, '='); eq > 0 {
env[kv[:eq]] = kv[eq+1:]
}
}
} else {
for k, v := range base {
env[k] = v
}
}
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
return env
}
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
-40
View File
@@ -46,46 +46,6 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
}
}
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
// xAI config now resolves from the baked-in preset.
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
r := NewRegistry()
// Baseline: a hermetic empty map keeps the preset inert.
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
t.Fatal("xai should not resolve without the opt-in")
}
// The helper forces the opt-in so the preset resolves.
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
if err != nil {
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
}
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
// overrides — critical because envValue treats a non-nil map as hermetic, so a
// partial map would silently drop them.
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
env := envWithPresetsAllowed(base)
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("opt-in flag not set: %v", env)
}
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
}
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig with override env: %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
-20
View File
@@ -41,26 +41,6 @@ func ValidateKey(key string) error {
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
// — the bearer token AND any header claim like chatgpt-account-id — comes from
// the SAME login; selecting independently per consumer could otherwise pair a
// bearer from one login with an account header from another. A load error on a
// candidate is treated as a miss (skip to the next), never a hard failure.
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
if store == nil {
return Token{}, "", false
}
for _, name := range candidates {
key := ProviderKey(name)
if token, ok, err := store.Load(key); err == nil && ok {
return token, key, true
}
}
return Token{}, "", false
}
// Status is a redaction-safe summary of a stored token (no secret material).
type Status struct {
Key string `json:"key"`
@@ -85,104 +85,6 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
}
}
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
// length via its native /api/show endpoint. The generic /v1/models probe
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
// listings don't carry context-window metadata, and a custom/local model tag
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
// through the local daemon) has no curated-catalog entry to borrow one from
// either, so modelContextWindow has nothing to show for it without this. Only
// meaningful for the local Ollama provider (baseURL like
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
// service and isn't assumed to expose the same endpoint.
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
model = strings.TrimSpace(model)
if model == "" {
return 0, fmt.Errorf("model name is required")
}
endpoint, err := ollamaShowEndpoint(baseURL)
if err != nil {
return 0, err
}
payload, err := json.Marshal(struct {
Model string `json:"model"`
}{Model: model})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return parseOllamaShowContextWindow(body)
}
// ollamaShowEndpoint derives the native Ollama API root from the
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
// appends /api/show.
func ollamaShowEndpoint(baseURL string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
}
path := strings.TrimRight(parsed.Path, "/")
path = strings.TrimSuffix(path, "/v1")
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
// parseOllamaShowContextWindow extracts the context length from an
// /api/show response. Ollama reports it under model_info with an
// architecture-prefixed key (e.g. "llama.context_length",
// "qwen2.context_length") — the prefix varies by model family, so this scans
// for any key ending in ".context_length" rather than hardcoding one.
func parseOllamaShowContextWindow(body []byte) (int, error) {
// model_info mixes value types (strings like "general.architecture", numbers
// like "*.context_length"), so decode values generically and only try to
// read a number out of the one key we actually care about.
var payload struct {
ModelInfo map[string]any `json:"model_info"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, fmt.Errorf("decode ollama show response: %w", err)
}
for key, value := range payload.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
if window, ok := value.(float64); ok && window > 0 {
return int(window), nil
}
}
return 0, fmt.Errorf("ollama show response did not report a context length")
}
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
endpoint, err := modelsEndpoint(profile.BaseURL)
if err != nil {
@@ -2,7 +2,6 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -268,61 +267,3 @@ func modelIDs(models []Model) []string {
}
return ids
}
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
// /v1/models probe never carries context-window metadata (parseModelsResponse
// only extracts id/description), so a custom/local Ollama model tag with no
// curated-catalog match has no other source for it. This exercises the
// Ollama-native /api/show fallback that fills that gap.
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
var gotPath, gotMethod, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 131072
}
}`))
}))
defer server.Close()
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
}
if window != 131072 {
t.Fatalf("context window = %d, want 131072", window)
}
if gotPath != "/api/show" {
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
t.Fatalf("request body = %q, want it to name the model", gotBody)
}
}
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
t.Fatal("expected an error for an empty model name")
}
}
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
}))
defer server.Close()
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
t.Fatal("expected an error when no *.context_length key is present")
}
}
+13 -31
View File
@@ -26,13 +26,6 @@ type Options struct {
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
// account from the exact login that issued the bearer token — never a second,
// independent lookup that could pick a different login (a backend-rejected
// mismatch).
OAuthLoginKey string
}
// New creates a runtime provider for a resolved provider profile.
@@ -258,22 +251,14 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
// the stored OAuth token's Account field on every request so a refresh that
// rotates the token (and its account claim) takes effect immediately — a static
// AccountID captured at construction would go stale on the first refresh.
//
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
// refreshes the token in place under that key; reading the account from the same
// key keeps header and token on one login for the provider's life. Resolving the
// key independently here (a second oauth.FirstStored) could, after a transient
// per-candidate load error or a mid-session `zero auth login`, pick a different
// login than the bearer — a mismatch the backend rejects.
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
account := codexAccountFromStore(profile.Name)
if account == "" {
return "", false, nil
}
@@ -303,20 +288,17 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
})
}
// codexAccountForKey reads the chatgpt_account_id from the token currently
// stored under key. Called per-request (via the resolver) so a refresh that
// rotates the token — saved back in place under the same key — takes effect
// immediately without restarting the agent. Returns "" when key is empty (no
// OAuth login), or the token is absent or carries no account claim.
func codexAccountForKey(key string) string {
if key == "" {
return ""
}
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
// token for the given provider name. It is called per-request (via the
// resolver) so a refresh that rotates the token takes effect immediately
// without restarting the agent. Returns "" when no token is stored (the
// Codex provider then omits the header and the user sees a clear 401).
func codexAccountFromStore(providerName string) string {
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return ""
}
token, ok, err := store.Load(key)
token, ok, err := store.Load(oauth.ProviderKey(providerName))
if err != nil || !ok {
return ""
}
+12 -64
View File
@@ -269,7 +269,6 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
@@ -323,10 +322,11 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
// The factory reads the stored OAuth token's Account field for the
// chatgpt-account-id header, from the login key the CALLER supplies in
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
// token in an isolated temp store, then pass that key.
store, err := newOAuthStoreForTest(t)
// chatgpt-account-id header. Seed a token in a temp store and point
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
dir := t.TempDir()
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
store, err := newOAuthStoreForTest()
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -344,9 +344,8 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
CatalogID: "chatgpt",
Model: "gpt-5",
}, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -408,61 +407,10 @@ func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
// path: an inherited "keyring" value would send NewStore to the OS keychain
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
// developer's real logins. Exists so the chatgpt factory tests can seed a
// token without copying the path-handling dance from internal/cli.
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
t.Helper()
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
// newOAuthStoreForTest builds a Store pointed at the current
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
// factory tests can seed a token without copying the path-handling dance
// from internal/cli.
func newOAuthStoreForTest() (*oauth.Store, error) {
return oauth.NewStore(oauth.StoreOptions{})
}
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
// therefore always read from the same login that issued the bearer (no second,
// independent selection). It re-reads per call, so an in-place token refresh (new
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
// or a missing/account-less token yields "".
func TestCodexAccountForKey(t *testing.T) {
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
t.Fatalf("Save chatgpt: %v", err)
}
// A different login WITHOUT an account claim, to prove the read stays on the
// requested key and never crosses to another login's account.
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
t.Fatalf("Save codex: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
t.Fatalf("account = %q, want acc-catalog-7", got)
}
// The bound key's token has no account → "", NOT the other login's account.
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
}
// Empty key (no OAuth login) → header omitted.
if got := codexAccountForKey(""); got != "" {
t.Fatalf("account for empty key = %q, want empty", got)
}
// Unknown key → "".
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
t.Fatalf("account for unknown key = %q, want empty", got)
}
// An in-place refresh under the bound key IS reflected per request.
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
t.Fatalf("refresh chatgpt: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
}
}
+12 -49
View File
@@ -6,12 +6,10 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -27,9 +25,9 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for ContentStallTimeout(idle). Without this an upstream that
// heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama reasoning
// models — would hang the agent indefinitely.
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
@@ -40,7 +38,7 @@ var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", ContentStallTimeout(idleTimeout))
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
@@ -55,32 +53,13 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// ContentStallTimeout bounds a heartbeat-but-no-output stream: keep-alives reset
// the idle watchdog (a heartbeating upstream is not "dead"), but if NO real data
// line arrives for this long the stream is aborted (ErrStreamStalled). Scaled
// above idleTimeout (so a slow-but-producing request that emits data between
// heartbeats is never killed — the watchdog only ever fires when NOTHING real
// arrives), but only 1.2× rather than the old 2×: a genuine heartbeat-pause
// stall on chatgpt/gpt-5.x rarely recovers, so a ~10-minute dead wait (at the 5m
// idle default) was a terrible UX for a doomed turn. At the default idle this is
// 6 minutes; it still scales with ZERO_STREAM_IDLE_TIMEOUT. A returned value <= 0
// (idle watchdog disabled) leaves the content watchdog off too.
func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
// 1.2× computed as idle + idle/5 (not idle*6/5), plus a clamp: a
// pathologically large ZERO_STREAM_IDLE_TIMEOUT could make idle*6 overflow
// int64 and wrap to a NEGATIVE duration, which would arm the content timer
// to fire immediately and abort every stream. Clamping to the max duration
// on overflow just means "effectively no content watchdog" — the sane
// result for an absurd idle timeout.
extra := idleTimeout / 5
if idleTimeout > math.MaxInt64-extra {
return math.MaxInt64
}
return idleTimeout + extra
}
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
@@ -143,21 +122,6 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
// unaffected (the stream-idle + content watchdogs cover that).
// - IdleConnTimeout is shortened so a connection that went idle across a pause
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
// - DisableKeepAlives on darwin only: ResponseHeaderTimeout/IdleConnTimeout
// above catch a reused connection that's fully dead (never responds) or
// idle past the timeout, but not one that's alive-but-severely-degraded —
// e.g. reused shortly after a prior request on the same host (well within
// 30s, common across quick retries of the same turn), where it still
// delivers real bytes, just at a crippled rate, resetting Zero's stream
// idle/content-stall watchdogs (which only fire on true silence) without
// ever recovering. That degraded-not-dead case is indistinguishable from
// genuine backend slowness from inside the stream, so the only reliable
// fix is removing pooling from the equation entirely on the one platform
// where this class of bug has actually reproduced. A fresh TCP+TLS
// handshake per request costs low tens of milliseconds — negligible next
// to the minutes-long stalls this avoids — and this doesn't touch
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
// pooled connections around as long.
var sharedHTTPClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
@@ -168,7 +132,6 @@ var sharedHTTPClient = func() *http.Client {
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
@@ -322,7 +285,7 @@ func ScanSSEDataWithContext(
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := ContentStallTimeout(idleTimeout)
contentTimeout := idleTimeout * streamContentStallFactor
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
@@ -4,9 +4,7 @@ import (
"context"
"errors"
"io"
"math"
"net/http"
"runtime"
"strings"
"testing"
"time"
@@ -205,36 +203,11 @@ func TestUpstreamUnreachable(t *testing.T) {
}
}
// ContentStallTimeout is 1.2× the idle timeout, disabled (0) when idle is
// disabled, and must never overflow to a negative duration for an absurdly
// large idle timeout (which would arm the content timer to fire immediately
// and abort every stream).
func TestContentStallTimeout(t *testing.T) {
cases := []struct {
idle time.Duration
want time.Duration
}{
{5 * time.Minute, 6 * time.Minute},
{30 * time.Second, 36 * time.Second},
{100 * time.Millisecond, 120 * time.Millisecond},
{0, 0},
{-1, 0},
}
for _, c := range cases {
if got := ContentStallTimeout(c.idle); got != c.want {
t.Fatalf("ContentStallTimeout(%v) = %v, want %v", c.idle, got, c.want)
}
}
// Overflow guard: idle*6 would wrap negative; the clamp keeps it positive.
if got := ContentStallTimeout(math.MaxInt64); got <= 0 {
t.Fatalf("ContentStallTimeout(MaxInt64) = %v, must stay positive (no overflow wrap)", got)
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
@@ -262,8 +235,7 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
// Keep-alives reset idle but not content.
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
@@ -286,7 +258,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 120ms content window,
// One data line every 30ms for ~300ms (well past the 200ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
@@ -318,16 +290,16 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (ContentStallTimeout) and must NOT claim the
// upstream "stopped sending data" (keep-alives were still arriving).
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 6m") {
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
@@ -352,17 +324,6 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
if tr.IdleConnTimeout != 30*time.Second {
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
}
// DisableKeepAlives is the stronger mitigation scoped to darwin only: the
// two timeouts above catch a reused connection that's fully dead or idle
// too long, but not one that's alive-and-degraded (still delivers real
// bytes, just at a crippled rate) — which resets Zero's stream watchdogs
// without ever recovering. Removing pooling from the equation entirely is
// only worth its reconnect cost on the platform this class of bug has
// actually reproduced on.
wantDisableKeepAlives := runtime.GOOS == "darwin"
if tr.DisableKeepAlives != wantDisableKeepAlives {
t.Fatalf("DisableKeepAlives = %v, want %v (GOOS=%s)", tr.DisableKeepAlives, wantDisableKeepAlives, runtime.GOOS)
}
if HTTPClient(nil) != got {
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
}
+1 -7
View File
@@ -124,16 +124,10 @@ func TestOutputToolBlocksUntilTaskCompletes(t *testing.T) {
tool := NewOutputTool(manager)
tool.PollInterval = time.Millisecond
// timeout is a ceiling, not a target: the test still returns as soon as the
// 5ms write lands (PollInterval keeps it responsive), it just tolerates a
// slow/contended CI runner without failing. A 100ms budget against a 5ms
// write only left 20x margin, which Windows CI runners (coarser default
// timer granularity, heavier scheduling contention than Linux/macOS) blew
// through and failed the test on an otherwise-unrelated PR merge.
result := tool.Run(context.Background(), map[string]any{
"task_id": "child_task",
"block": true,
"timeout": 2000,
"timeout": 100,
})
if result.Status != tools.StatusOK || !strings.Contains(result.Output, "output:\nfinished") {
-10
View File
@@ -630,16 +630,6 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
if session.output.consumeTruncated() {
truncated = true
}
// A background process that keeps writing output faster than this
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
// a crash-restart spew) would otherwise never let drainString return
// empty, so the deadline check below — only reached on an empty
// drain — would never fire. That turns `wait` into an unbounded
// block regardless of what the caller asked for. Check it here too,
// so continuous output can never starve the timeout.
if time.Now().After(deadline) {
return finish()
}
continue
}
if session.doneClosed() {
-59
View File
@@ -450,65 +450,6 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
}
}
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
// close to its requested deadline even while output keeps arriving. Before
// the corresponding fix, the deadline was only checked in the branch reached
// when drainString returns empty — a background process producing output
// fast/continuously enough to keep the buffer perpetually non-empty could
// theoretically starve that check indefinitely. This synthetic writer
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
// Go's scheduler in practice — the reader still won the race often enough —
// so this test doesn't prove the old code could hang; it just pins down the
// intended behavior (bounded by wait) going forward.
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
session := &execSession{
id: 1000,
output: newExecOutputBuffer(),
done: make(chan struct{}),
}
stop := make(chan struct{})
var wg sync.WaitGroup
// Several concurrent writers, each pushing a decent-sized chunk in a tight
// loop: a single slow writer lets the reader win the race often enough
// (the runtime schedules a gap between writes) to mask the bug. Enough
// parallel writers keep the buffer non-empty essentially continuously,
// closer to a real chatty process whose PTY reads land in bursts.
const writers = 8
chunk := []byte(strings.Repeat("x", 256))
wg.Add(writers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
session.output.Write(chunk)
}
}
}()
}
t.Cleanup(func() {
close(stop)
wg.Wait()
})
const wait = 200 * time.Millisecond
start := time.Now()
_, _ = session.collect(context.Background(), wait)
elapsed := time.Since(start)
// Generous slack over `wait` for scheduling jitter under a continuously
// writing goroutine — this must stay a small multiple of wait, not
// "however long the writer keeps going" (which is what the bug produced:
// this test would hang past the 30s test timeout without the fix).
if elapsed > 3*wait {
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
-7
View File
@@ -56,13 +56,6 @@ func pasteFromClipboardCmd() tea.Cmd {
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+7 -19
View File
@@ -490,16 +490,16 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", nil
return m, "Model\nCannot switch providers while a run is active."
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
return m, "Model\nProvider rebuild is not available for this TUI session."
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
return m, "Model\nunknown provider " + strconv.Quote(providerName)
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
@@ -508,11 +508,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
}
m.provider = next
m.providerProfile = target
@@ -524,19 +524,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
}
// Warm discovery for the provider we just switched to, same as Init() does
// for the provider active at launch — otherwise the context-usage gauge has
// no window for this provider until /model happens to be opened separately.
var cmds []tea.Cmd
if hasDescriptor {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
cmds = append(cmds, cmd)
}
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
+1 -1
View File
@@ -467,7 +467,7 @@ func providerNextActionLines(profile config.ProviderProfile, snapshot zerocomman
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
+2 -2
View File
@@ -281,9 +281,9 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/theme",
usage: "/theme [list|auto|name]",
usage: "/theme [auto|dark|light]",
group: commandGroupSession,
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
description: "Switch or show the color theme (auto-detects terminal background).",
kind: commandTheme,
},
{
-26
View File
@@ -27,29 +27,3 @@ func TestContextWindowSegment(t *testing.T) {
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
}
}
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
// figure and the plain usage segment's token count both read the exact same
// latestUsageTokens value — once the gauge is showing it, the usage segment
// must fall back to cost-only, or the same number renders twice side by
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
if _, err := m.usageTracker.Record(usage.RecordInput{
ModelID: "claude-sonnet-4.5",
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
}); err != nil {
t.Fatalf("Record: %v", err)
}
status := plainRender(t, m.statusLine(110))
if !strings.Contains(status, "161K/200K") {
t.Fatalf("status line = %q, expected the context gauge to render", status)
}
if strings.Count(status, "161K") != 1 {
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
}
if strings.Contains(status, "tok") {
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
}
}
-2
View File
@@ -122,8 +122,6 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
}
-59
View File
@@ -1,59 +0,0 @@
package tui
import (
"context"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
// delivered automatically on program start, but that's the terminal
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
// back to the unpadded, non-fullscreen render path for the whole session (the
// alt-screen viewport never gets filled below the actual content). Init must
// explicitly request it too, the same way it already explicitly requests the
// background color rather than relying solely on an unprompted push.
func TestInitRequestsWindowSize(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
cmd := m.Init()
if cmd == nil {
t.Fatal("Init() must return a non-nil command")
}
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
if !batchContainsCmd(t, cmd, want) {
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
}
}
// batchContainsCmd reports whether cmd (Init()'s single top-level
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
// (the same technique TestThemeAutoReProbesBackground uses for
// tea.RequestBackgroundColor — reliable for named top-level functions like
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
// them, since Init()'s batch also carries real closures (e.g. provider model
// discovery) that make live network calls and must not fire in a unit test.
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
t.Helper()
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == want {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, sub := range batch {
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
return true
}
}
return false
}
+1 -1
View File
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Esc", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
+95 -261
View File
@@ -25,7 +25,6 @@ import (
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -41,15 +40,6 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
@@ -58,41 +48,40 @@ const dragEdgeScrollInterval = 70 * time.Millisecond
const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
// titledSessions records session ids for which a model-generated title has
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
@@ -307,14 +296,6 @@ type model struct {
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
@@ -349,12 +330,6 @@ type model struct {
// catalog descriptor ID), so /model shows each provider's real current models —
// the same list the provider-setup wizard discovers — not the static catalog.
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
// ollamaContextWindowByModel holds context-window sizes fetched from a local
// Ollama daemon's native /api/show endpoint (keyed by model name), for
// custom/local models that have no curated-catalog entry and whose
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
// see modelContextWindow.
ollamaContextWindowByModel map[string]int
// pendingImages holds image attachments staged by /image for the next user
// turn; pendingImageLabels are their display names (base(path)) for the chip
@@ -383,12 +358,6 @@ type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
@@ -676,57 +645,56 @@ func newModel(ctx context.Context, options Options) model {
notifier.SetFocused(true)
m := model{
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
ctx: ctx,
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
@@ -791,18 +759,6 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
// nothing else ever asks again: m.height stays its zero value forever,
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
// unpadded, non-fullscreen render path for the rest of the session, and
// the alt-screen viewport never gets filled below the actual content.
// Explicitly requesting it here means Zero doesn't depend solely on the
// terminal's unprompted push — mirrors the RequestBackgroundColor request
// below for the same reason.
cmds = append(cmds, tea.RequestWindowSize)
// In auto mode, ask the terminal for its background color; the reply arrives
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
if m.themeMode == themeAuto {
@@ -817,13 +773,6 @@ func (m model) Init() tea.Cmd {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
cmds = append(cmds, cmd)
}
// The generic discovery above has no source for a local Ollama model's
// context window (see ollamaContextWindowDiscoveryCmd); probe its
// native /api/show separately so the gauge works for custom/local
// Ollama models too, not just ones in the curated catalog.
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.prService != nil && m.runtimeMessageSink != nil {
service := m.prService
@@ -900,14 +849,6 @@ func (m model) disarmExitConfirmation() model {
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
@@ -986,11 +927,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
@@ -1038,13 +974,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
@@ -1067,15 +996,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
@@ -1127,11 +1047,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
if m.picker.kind == pickerTheme {
// A live theme preview was applied while navigating; restore the
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
m.picker = nil
return m, nil
}
@@ -1141,24 +1056,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
m.cancelRun()
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
@@ -1279,9 +1181,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.deleteQueryRune()
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -1354,7 +1253,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -1396,7 +1295,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -1455,9 +1354,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if keyPrintable(msg) {
m.picker.appendQuery(keyRunes(msg))
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
}
return m, nil
}
@@ -1714,7 +1610,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
@@ -2016,14 +1911,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
m.ollamaContextWindowByModel = map[string]int{}
}
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
}
return m, nil
case mcpCommandResultMsg:
return m.applyMCPCommandResultMessage(msg), nil
}
@@ -2047,17 +1934,6 @@ func (m model) View() tea.View {
view := tea.NewView(content)
view.AltScreen = m.altScreen
// Paint the whole frame with the active theme's surface. Zero never paints the
// terminal's own canvas, so without this a theme's text falls on the terminal
// background — fine when they share polarity, but a light theme's dark text on a
// dark terminal (or vice versa) is invisible, and a color theme never shows its
// real surface. Painting the panel makes every theme self-contained and legible
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
// picker) too. Alt-screen only, so inline output never leaves a painted
// background behind in the user's scrollback after exit.
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
// AllMotion (not CellMotion) is required for hover highlighting: it
@@ -2235,18 +2111,15 @@ func (m model) footerView(width int) string {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
footer.WriteString("\n")
// A message typed while a run is active is queued for the next turn; show its
// preview directly ABOVE the input box (not below), so it reads as "waiting to
// send" sitting on top of what you're currently typing.
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString(queued)
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if hint := m.composerDescriptionHint(width); hint != "" {
footer.WriteString("\n")
footer.WriteString(hint)
}
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString("\n")
footer.WriteString(queued)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
@@ -2822,15 +2695,6 @@ const quietWorkingHint = 8 * time.Second
// quietGenerationHint returns a "still generating…" cue with an advancing
// quiet-timer when the active turn has produced no streamed output for a while,
// else "". The advancing number is itself the liveness signal.
//
// Past half the provider's idle timeout, the cue escalates to name what's
// actually happening and when Zero will act on its own: a heartbeating-but-
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
// providerio.ContentStallTimeout(idle), but until it fires this exact same
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
return ""
@@ -2846,10 +2710,6 @@ func (m model) quietGenerationHint() string {
if quiet < quietWorkingHint {
return ""
}
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
ceiling := providerio.ContentStallTimeout(idleTimeout)
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
}
return "still generating… " + formatWorkingElapsed(quiet)
}
@@ -3405,20 +3265,14 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
item, ok := picker.current()
if !ok {
if picker.kind == pickerTheme {
// No selectable row (e.g. the filter matched nothing): undo any live
// preview so the palette matches the committed m.themeMode.
m.restoreCommittedTheme()
}
return m, nil
}
var cmd tea.Cmd
switch picker.kind {
case pickerModel:
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text, cmd = m.switchProviderModel(owner, item.Value)
m, text = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3435,20 +3289,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
text := ""
m, text = m.handleThemeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so a committed `auto` re-detects
// light/dark instead of reusing the preview's reading — mirrors the
// text /theme dispatch (M17).
return m, tea.RequestBackgroundColor
}
}
return m, cmd
return m, nil
}
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
@@ -3702,13 +3544,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
// Bare `/theme` opens the popup picker (live preview on move, apply on
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
// (or `/theme list`) still runs the text handler directly.
if strings.TrimSpace(command.text) == "" {
m.picker = m.newThemePicker()
return m, nil
}
text := ""
m, text = m.handleThemeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
@@ -3972,8 +3807,7 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
-7
View File
@@ -84,13 +84,6 @@ func (m model) modelContextWindow(modelName string) int {
return window
}
}
// A custom/local Ollama model tag has no curated-catalog entry, and its
// OpenAI-compatible /v1/models listing (the discoveredContextWindow source
// above) doesn't carry context-window metadata at all — see
// ollamaContextWindowDiscoveryCmd, the only other source for this.
if window := m.ollamaContextWindowByModel[trimmed]; window > 0 {
return window
}
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
// applies its own positive fallback via modelregistry.AgentContextWindow.
return 0
+4 -200
View File
@@ -1108,221 +1108,25 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
}
}
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
func TestEscCancelsPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
t.Fatal("expected Esc to cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
t.Fatal("expected Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
func TestAgentResponseCompletesStuckPlan(t *testing.T) {
+2 -14
View File
@@ -40,15 +40,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A click is a deliberate action, same as a keypress — it means the user
// moved on to something else, so it disarms a stale Esc cancel-confirmation
// rather than leaving it armed for some later, unrelated Esc to act on.
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
// continuously while the mouse merely sits over the terminal and would
// otherwise defeat the confirmation window on every render tick.
if mouseLeftPress(msg) || mouseRightPress(msg) {
m = m.disarmCancelConfirmation()
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
@@ -139,7 +130,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -171,7 +162,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -450,9 +441,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+4 -9
View File
@@ -44,9 +44,6 @@ func oauthDevicePrepare(name string) (oauth.DeviceAuth, oauth.Config, error) {
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
OpenBrowser: browser.OpenURL,
// Device-flow providers (e.g. Hugging Face) rely on the baked-in preset for
// their endpoints; opt in so the device code can be requested.
AllowPresets: true,
})
if err != nil {
return oauth.DeviceAuth{}, oauth.Config{}, err
@@ -65,9 +62,8 @@ func oauthDeviceComplete(name string, cfg oauth.Config, auth oauth.DeviceAuth) e
return err
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
AllowPresets: true, // preset config is needed to poll/exchange the device token
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
})
if err != nil {
return err
@@ -88,9 +84,8 @@ func oauthStoredToken(ctx context.Context, providerID string) string {
return ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AllowPresets: true, // refreshing a preset-provider token re-resolves its config
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
if err != nil {
return ""
-9
View File
@@ -755,15 +755,6 @@ func (m model) completeSetup() (tea.Model, tea.Cmd) {
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
-40
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -1536,42 +1535,3 @@ func absInt(value int) int {
}
return value
}
// Completing setup switches the live provider, so it must export ZERO_PROVIDER
// exactly like the /model, /provider, and wizard switch paths — a stale value
// from an earlier switch would otherwise win over config in every spawned
// child (applyEnv) and pin specialists/swarm members to the OLD provider's
// credentials.
func TestCompleteSetupExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-previous-provider")
m := newModel(context.Background(), Options{
Setup: SetupOptions{
Visible: true,
Providers: []SetupProviderOption{
{ID: "openai", Name: "OpenAI", DefaultModel: "gpt-4.1", EnvVar: "OPENAI_API_KEY", RequiresAuth: true},
},
Save: func(selection SetupSelection) (SetupResult, error) {
return SetupResult{
Provider: config.ProviderProfile{
Name: selection.CatalogID,
CatalogID: selection.CatalogID,
Model: selection.Model,
},
}, nil
},
},
})
m.width = 100
m.height = 30
m.setup.stage = setupStageReady
updated, _ := m.completeSetup()
next := updated.(model)
if next.providerName == "" {
t.Fatal("setup completion should have set a provider name")
}
if got := os.Getenv(config.ActiveProviderEnv); got != next.providerName {
t.Fatalf("%s = %q after setup save, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, next.providerName)
}
}
+30 -35
View File
@@ -20,46 +20,41 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
RuntimeMessageSink func(tea.Msg)
Registry *tools.Registry
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
Cwd string
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
RuntimeMessageSink func(tea.Msg)
Registry *tools.Registry
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
// Theme is the operator's palette preference: "auto" (default), a built-in
// ("dark"/"light"), or a registered color theme. Set from the --theme flag;
// falls back to ZERO_THEME, then the persisted SavedTheme, then auto.
Theme string
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// Theme is the operator's palette preference: "auto" (default), "dark", or
// "light". Set from the --theme flag; falls back to ZERO_THEME then auto.
Theme string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
-82
View File
@@ -23,7 +23,6 @@ const (
pickerModel pickerKind = iota
pickerEffort
pickerSession
pickerTheme
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -565,46 +564,6 @@ type modelPickerModelsDiscoveredMsg struct {
err error
}
// ollamaContextWindowDiscoveredMsg carries the result of an async /api/show
// probe against a local Ollama daemon (see ollamaContextWindowDiscoveryCmd).
// A zero contextWindow (or a non-nil err) means the probe found nothing
// usable and the map is left untouched, not zeroed out.
type ollamaContextWindowDiscoveredMsg struct {
modelName string
contextWindow int
err error
}
// ollamaContextWindowDiscoveryCmd probes a local Ollama daemon's native
// /api/show endpoint for modelName's context length. Scoped to the local
// Ollama provider only (catalog ID "ollama", not "ollama-cloud" — a
// different hosted service this endpoint isn't assumed to exist on): the
// generic /v1/models discovery has no source for this metadata for
// custom/local model tags, so without this the context gauge just never
// shows for them (see modelContextWindow).
func (m model) ollamaContextWindowDiscoveryCmd(descriptor providercatalog.Descriptor, baseURL string, modelName string) tea.Cmd {
if descriptor.ID != "ollama" {
return nil
}
modelName = strings.TrimSpace(modelName)
baseURL = strings.TrimSpace(baseURL)
if modelName == "" || baseURL == "" {
return nil
}
discover := m.discoverOllamaContextWindow
if discover == nil {
discover = func(ctx context.Context, baseURL string, model string) (int, error) {
return providermodeldiscovery.DiscoverOllamaContextWindow(ctx, baseURL, model, providermodeldiscovery.Options{})
}
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
defer cancel()
window, err := discover(ctx, baseURL, modelName)
return ollamaContextWindowDiscoveredMsg{modelName: modelName, contextWindow: window, err: err}
}
}
func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor) config.ProviderProfile {
profile := m.providerProfile
normalizeIdentity := profileMatchesProviderBaseURL(profile, provider) ||
@@ -767,44 +726,3 @@ func (m model) newEffortPicker() *commandPicker {
}
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
if m.picker == nil {
return
}
m.picker.move(delta)
m.previewSelectedTheme()
}
+4 -104
View File
@@ -14,7 +14,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -640,17 +639,14 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
func TestThemeCommandOpensNoPicker(t *testing.T) {
// /theme keeps the existing shell-only message; no picker opens.
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
if m.picker != nil {
t.Fatal("/theme should not open a picker")
}
}
@@ -879,100 +875,4 @@ func TestModelContextWindowResolution(t *testing.T) {
if got := m.modelContextWindow(""); got != 0 {
t.Fatalf("empty model name should yield 0, got %d", got)
}
// A custom/local Ollama model tag has no curated-catalog entry and its
// generic /v1/models listing carries no window either — the only source is
// the Ollama-specific /api/show probe, landed via ollamaContextWindowByModel.
m.ollamaContextWindowByModel = map[string]int{"kimi-k2.7-code:cloud": 131072}
if got := m.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
t.Fatalf("ollama-discovered window should be used for an unregistered model, got %d", got)
}
}
// TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama: the /api/show probe
// only makes sense against a local Ollama daemon (catalog ID "ollama") — a
// different provider like "ollama-cloud" is a distinct hosted service this
// endpoint isn't assumed to exist on, and every other provider already has
// its context window covered by the curated catalog or /v1/models discovery.
func TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama(t *testing.T) {
m := model{ctx: context.Background()}
ollama, ok := providercatalog.Get("ollama")
if !ok {
t.Fatal("expected \"ollama\" to be a registered catalog descriptor")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", "kimi-k2.7-code:cloud"); cmd == nil {
t.Fatal("expected a discovery command for the local ollama provider")
}
ollamaCloud, ok := providercatalog.Get("ollama-cloud")
if !ok {
t.Fatal("expected \"ollama-cloud\" to be a registered catalog descriptor")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollamaCloud, "https://ollama.com/v1", "qwen3-coder:480b"); cmd != nil {
t.Fatal("ollama-cloud is a different hosted service; must not probe /api/show against it")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "", "kimi-k2.7-code:cloud"); cmd != nil {
t.Fatal("an empty base URL should yield no command")
}
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", ""); cmd != nil {
t.Fatal("an empty model name should yield no command")
}
}
// TestOllamaContextWindowDiscoveredMsgPopulatesMap verifies the Update()
// handler for the async probe's result actually reaches modelContextWindow's
// fallback source, and that a failed/zero probe leaves the map untouched
// rather than caching a bogus zero.
func TestOllamaContextWindowDiscoveredMsgPopulatesMap(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, cmd := m.Update(ollamaContextWindowDiscoveredMsg{modelName: "kimi-k2.7-code:cloud", contextWindow: 131072})
next := updated.(model)
if cmd != nil {
t.Fatal("applying a discovered window should not schedule further work")
}
if got := next.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
t.Fatalf("modelContextWindow after discovery = %d, want 131072", got)
}
updated, _ = next.Update(ollamaContextWindowDiscoveredMsg{modelName: "other-model", contextWindow: 0, err: errors.New("boom")})
next = updated.(model)
if got := next.modelContextWindow("other-model"); got != 0 {
t.Fatalf("a failed probe must not populate a window, got %d", got)
}
}
// TestSwitchProviderModelWarmsDiscoveryForTheNewProvider: switching providers
// mid-session (e.g. via the /model picker) previously fired no discovery at
// all for the newly active provider — Init() only warms the provider active
// at launch — so the context gauge stayed blank for the rest of the session
// unless /model happened to be reopened separately. Switching to Ollama
// specifically must also warm the /api/show probe, the only source of a
// context window for custom/local Ollama models.
func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-5.1",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
SavedProviders: []config.ProviderProfile{
{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
}
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
}
if cmd == nil {
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
}
}
+3 -47
View File
@@ -20,7 +20,6 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// providerWizardOAuthMsg carries the result of an in-wizard browser OAuth login.
@@ -167,10 +166,6 @@ func runProviderTokenLogin(name string) error {
Store: store,
HTTPClient: &http.Client{Timeout: 60 * time.Second},
OpenBrowser: browser.OpenURL,
// The user explicitly chose to sign in with this provider's OAuth, so opt
// into its baked-in preset (e.g. xAI's public client_id); without this the
// config never resolves and the browser never opens.
AllowPresets: true,
})
if err != nil {
return err
@@ -888,20 +883,13 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
modelChoice := wizard.currentModel()
profile := providerWizardProfile(provider, modelChoice.ID, wizard.apiKey, wizard.baseURL, wizard.profileName)
runtimeProfile := providerWizardRuntimeProfile(profile)
// Build and persist into LOCALS first, committing live state only once BOTH
// succeed. A persist failure (read-only config, disk full) must not leave the
// chat running on the new provider while the status line, m.providerProfile,
// and the ZERO_PROVIDER export (which pins spawned children) still point at
// the old one — parent and children would silently run on different providers.
var nextProvider zeroruntime.Provider
if m.newProvider != nil {
built, err := m.newProvider(runtimeProfile)
nextProvider, err := m.newProvider(runtimeProfile)
if err != nil {
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{profile.APIKey, runtimeProfile.APIKey}})
return m, nil
}
nextProvider = built
m.provider = nextProvider
}
if strings.TrimSpace(m.userConfigPath) != "" {
// Capture flip: move the freshly entered key into the encrypted credential
@@ -911,24 +899,12 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
profile = config.SecureProviderProfile(profile, m.userConfigPath)
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
return m, nil // nothing committed to live state yet
return m, nil
}
}
// Both succeeded — commit the live provider, profile, model, and the child
// env export together, so they can never disagree.
if nextProvider != nil {
m.provider = nextProvider
}
m.providerProfile = profile
m.providerName = profile.Name
m.modelName = profile.Model
// Keep sub-agent child processes on the same provider we just switched to —
// same as the /model and /provider switch paths (command_center.go). Without
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
// OLD provider and wins over config in every spawned child (applyEnv), so
// specialists/swarm members run on the wrong provider's credentials.
config.SetActiveProviderEnv(profile.Name)
m.providerWizard = nil
return m, nil
}
@@ -1253,29 +1229,9 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
for offset, provider := range wizard.providers[start : start+maxVisible] {
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
}
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
// so the error must render here or the click looks like a silent no-op. The
// credential step renders its own copy for the ctrl+o path.
if wizard.oauthMode && wizard.oauthErr != "" {
lines = append(lines, "", zeroTheme.red.Render("OAuth login failed: "+wizard.oauthErr))
if hint := providerWizardOAuthErrHint(wizard.currentProvider()); hint != "" {
lines = append(lines, zeroTheme.faint.Render(hint))
}
}
return lines
}
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
// can only work once the operator registers an app and supplies its client_id.
func providerWizardOAuthErrHint(provider providercatalog.Descriptor) string {
if strings.EqualFold(provider.ID, "huggingface") {
return "Hugging Face needs your own app: create one at " +
"https://huggingface.co/settings/applications/new, then set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID."
}
return ""
}
func (wizard *providerWizardState) renderSelectableProvider(width int, index int, provider providercatalog.Descriptor) string {
selected := index == wizard.selectedProvider
surface := transparentSurface
@@ -125,36 +125,6 @@ func TestProviderWizardDeviceCodeMsgShowsCodeAndPolls(t *testing.T) {
}
}
// A failed OAuth attempt leaves the wizard on the provider list; the error must be
// rendered there (not just on the credential step) so a click isn't a silent
// no-op, and Hugging Face gets an actionable client_id hint.
func TestProviderStepSurfacesOAuthError(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
wizard := m.providerWizard
wizard.oauthMode = true
wizard.providers = providerWizardOAuthDescriptors()
found := false
for i, p := range wizard.providers {
if strings.EqualFold(p.ID, "huggingface") {
wizard.selectedProvider = i
found = true
}
}
if !found {
t.Fatal("huggingface should be an OAuth-capable provider")
}
wizard.oauthErr = `oauth: provider "huggingface" is not configured; set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`
view := strings.Join(wizard.renderProviderStep(72), "\n")
if !strings.Contains(view, "OAuth login failed") {
t.Fatalf("provider step should surface the OAuth error:\n%s", view)
}
if !strings.Contains(view, "huggingface.co/settings/applications/new") {
t.Fatalf("Hugging Face hint should point at app registration:\n%s", view)
}
}
func TestProviderWizardDeviceErrorSurfaced(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
-101
View File
@@ -1189,104 +1189,3 @@ func containsString(values []string, want string) bool {
}
return false
}
// Applying the wizard switches the live provider, so it must export
// ZERO_PROVIDER exactly like the /model and /provider switch paths — a stale
// value from an earlier switch would otherwise win over config in every
// spawned child (applyEnv) and pin specialists/swarm members to the OLD
// provider's credentials.
func TestApplyProviderWizardExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-previous-provider")
m := newModel(context.Background(), Options{})
// Isolate the SUCCESS path's persist to a temp config (the default empty
// path would skip persist; a future default must never reach the real user
// config), and stub the build so the full commit sequence runs.
m.userConfigPath = filepath.Join(t.TempDir(), "config.json")
m.newProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
}
m.providerWizard = &providerWizardState{
step: providerWizardStepModel,
profileName: "acme-wizard",
providers: []providercatalog.Descriptor{{
ID: "openrouter",
Name: "OpenRouter",
Transport: providercatalog.TransportOpenAICompatible,
DefaultBaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4.1",
AuthEnvVars: []string{"OPENROUTER_API_KEY"},
RequiresAuth: true,
SupportedAPIFormats: []providercatalog.APIFormat{providercatalog.APIFormatOpenAIChatCompletions},
}},
models: []providerWizardModel{{ID: "openai/gpt-4.1", Description: "GPT-4.1"}},
}
updated, _ := m.applyProviderWizard()
next := updated
if next.providerName == "" {
t.Fatal("wizard apply should have set a provider name")
}
if got := os.Getenv(config.ActiveProviderEnv); got != next.providerName {
t.Fatalf("%s = %q after wizard apply, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, next.providerName)
}
}
// On a config PERSIST failure, applyProviderWizard must leave live state fully
// unchanged — the chat must NOT already be running on the new provider while the
// status line and the ZERO_PROVIDER export (which pins spawned children) still
// point at the old one. Build and persist are staged into locals; nothing is
// committed unless both succeed.
func TestApplyProviderWizardPersistFailureLeavesLiveStateUnchanged(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain: apiKey is secured before the persist fails
t.Setenv(config.ActiveProviderEnv, "old-provider")
// A config path whose parent is a regular FILE, so writeConfigFile's MkdirAll
// fails and UpsertProvider returns an error.
blocker := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
t.Fatalf("seed blocker file: %v", err)
}
brokenConfigPath := filepath.Join(blocker, "config.json")
oldProvider := &fakeProvider{}
newProvider := &fakeProvider{}
m := newModel(context.Background(), Options{})
m.provider = oldProvider
m.providerProfile = config.ProviderProfile{Name: "old-provider"}
m.providerName = "old-provider"
m.userConfigPath = brokenConfigPath
m.newProvider = func(config.ProviderProfile) (zeroruntime.Provider, error) { return newProvider, nil }
m.providerWizard = &providerWizardState{
step: providerWizardStepModel,
profileName: "acme-new",
providers: []providercatalog.Descriptor{{
ID: "openrouter",
Name: "OpenRouter",
Transport: providercatalog.TransportOpenAICompatible,
DefaultBaseURL: "https://openrouter.ai/api/v1",
DefaultModel: "openai/gpt-4.1",
AuthEnvVars: []string{"OPENROUTER_API_KEY"},
RequiresAuth: true,
SupportedAPIFormats: []providercatalog.APIFormat{providercatalog.APIFormatOpenAIChatCompletions},
}},
models: []providerWizardModel{{ID: "openai/gpt-4.1", Description: "GPT-4.1"}},
apiKey: "sk-new",
}
updated, _ := m.applyProviderWizard()
next := updated
if next.providerWizard == nil || next.providerWizard.err == "" {
t.Fatal("a persist failure must surface a wizard error and keep the wizard open")
}
if next.provider != oldProvider {
t.Fatal("live provider must NOT be swapped when the persist fails")
}
if next.providerName != "old-provider" {
t.Fatalf("providerName = %q, want it unchanged on persist failure", next.providerName)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "old-provider" {
t.Fatalf("%s = %q, want it unchanged on persist failure (children would diverge from parent)", config.ActiveProviderEnv, got)
}
}
-32
View File
@@ -55,38 +55,6 @@ func TestQueuedPromptPreviewAppearsInView(t *testing.T) {
}
}
// TestQueuedPromptPreviewSitsAboveComposer: the queued-message preview must
// render ABOVE the input box, not below it — a message waiting to send should
// sit on top of what you're currently typing.
func TestQueuedPromptPreviewSitsAboveComposer(t *testing.T) {
m := newQueuedMessageTestModel(t)
m.pending = true
m.activeRunID = 1
m.runID = 1
m.width = 96
m.input.SetValue("summarize the failing test output")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
footer := plainRender(t, next.footerView(96))
lines := strings.Split(footer, "\n")
queuedLine, composerTopLine := -1, -1
for i, ln := range lines {
if queuedLine < 0 && strings.Contains(ln, "queued") {
queuedLine = i
}
if composerTopLine < 0 && strings.Contains(ln, "╭") { // composer box top border
composerTopLine = i
}
}
if queuedLine < 0 || composerTopLine < 0 {
t.Fatalf("expected both a queued line and a composer box; queued=%d composer=%d\n%s", queuedLine, composerTopLine, footer)
}
if queuedLine >= composerTopLine {
t.Fatalf("queued preview (line %d) must sit ABOVE the composer box top border (line %d):\n%s", queuedLine, composerTopLine, footer)
}
}
func TestAgentResponseLaunchesQueuedPrompt(t *testing.T) {
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "queued answer"},
+1 -1
View File
@@ -36,7 +36,7 @@ func pickerBusyText(name string) string {
Title: "Busy",
Lines: []string{"Can't change " + label + " while a run is in progress."},
}},
Hints: []string{"press Esc twice to cancel the run, then try again"},
Hints: []string{"press Esc to cancel the run, then try again"},
})
}
+1 -6
View File
@@ -775,8 +775,6 @@ func TestEscCancelRecordsSessionError(t *testing.T) {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
@@ -838,10 +836,7 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
next = updated.(model)
if _, ok := runtimeMsg.(permissionRequestMsg); ok {
// Cancel mid-run via Esc while the permission prompt is pending: this
// unblocks the goroutine through ctx cancellation. Esc now requires a
// second press within the confirmation window to actually cancel.
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
// unblocks the goroutine through ctx cancellation.
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
cancelled = true
+71 -6
View File
@@ -12,8 +12,7 @@ import (
// may be swapped at startup (background detection / ZERO_THEME / --theme) or live
// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays
// and renders plain text when there is no TTY (tests). Every renderer consumes
// these named styles — no hex literal may appear outside theme_palettes.go (the
// palette tables + theme registry).
// these named styles — no hex literal may appear outside this file.
type tuiTheme struct {
// Base tokens.
ink lipgloss.Style // primary text
@@ -93,10 +92,9 @@ type tuiTheme struct {
}
// palette is the raw color-token table for one theme. buildTheme turns it into a
// resolved tuiTheme. The palette literals and the ordered theme registry live in
// theme_palettes.go (the only place hex literals live). Dark palettes keep tints
// darker than ink so every pairing survives 256-color downsampling; light palettes
// are dark-on-light with the same intent inverted.
// resolved tuiTheme. darkPalette and lightPalette are the ONLY place hex literals
// live. All dark tints stay darker than ink so every pairing survives 256-color
// downsampling; the light set is dark-on-light with the same intent inverted.
type palette struct {
panel string // card backgrounds (the terminal canvas itself is never painted full-bleed)
promptBg string // submitted user prompt background
@@ -127,6 +125,73 @@ type palette struct {
cardPerm string // permission card border (amber mixed into line)
}
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f",
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// lightPalette is dark-on-light: light-gray surfaces with near-black ink. The
// muted/faint/faintest grays get progressively LIGHTER (toward the surface) so the
// same text hierarchy reads on white; the lime accent is darkened to keep contrast
// against a light background, and the diff/permission tints become light surfaces.
var lightPalette = palette{
panel: "#ececed", // card backgrounds (slightly off-white)
promptBg: "#dcdce0", // submitted prompt background
line: "#cfcfd5", // default borders
line2: "#b0b0b8", // emphasized borders
ink: "#1b1b1d", // primary text (near-black)
muted: "#4a4a52", // secondary text — darkened so it clearly out-ranks faint on light
faint: "#57575f", // hints, metadata — nudged down to separate from faintest
faintest: "#646469", // line numbers, separators — pinned at the WCAG-AA floor on the light panel
accent: "#477006", // brand lime, darkened to AA contrast on light
green: "#1c7a4a", // success / diff add
red: "#c0322c", // errors / diff del
amber: "#8f6200", // permission / warnings
blue: "#1d5fd6", // grep locations
gitAdd: "#2f7a3a", // footer PR diff additions
gitDel: "#a83a3a", // footer PR diff deletions
addBg: "#e2f3e6", // diff added-line bg (light green)
delBg: "#fbe6e6", // diff deleted-line bg (light red)
addBgWord: "#97d3ab", // changed span within an added line — deeper green (sep 1.49 vs addBg, addInk 4.7:1)
delBgWord: "#f1b3b3", // changed span within a deleted line — deeper red (sep 1.48 vs delBg, delInk 4.6:1)
permBg: "#fbf0d8", // permission card bg (light amber)
selBg: "#cfe78f", // selected row bg (light accent) — deepened from #e7f2cd so it separates from the near-white panel (sep 1.01→1.15); ink label stays >12:1, faint ~5:1
addInk: "#1d5b37", // added-line text
delInk: "#8e2d2d", // deleted-line text
onAccent: "#ffffff", // text on the (dark) accent/amber fills
cardRun: "#94ad60", // running card border
cardErr: "#cf9a9a", // errored card border
cardPerm: "#c9ad7d", // permission card border
}
// buildTheme resolves a palette into the styles every renderer uses.
func buildTheme(p palette) tuiTheme {
col := func(s string) color.Color { return lipgloss.Color(s) }
-457
View File
@@ -1,457 +0,0 @@
package tui
import "strings"
// theme_palettes.go is the sole home of raw color hex in the TUI: every palette
// literal and the ordered theme registry live here, so theme.go stays hex-free
// (just the palette struct, buildTheme, and the resolved tuiTheme). Adding a theme
// is a new palette{...} literal plus one themeRegistry entry — nothing else.
//
// Contrast discipline: every palette must satisfy the same WCAG invariants the
// built-ins do (asserted across the whole registry in theme_select_test.go): ink
// and faintest ≥ AA on panel; onAccent ≥ AA on accent; addInk/delInk readable on
// their diff bands and word spans; the muted>faint>faintest ramp monotonic toward
// the surface; and selBg visibly separated from panel while its label stays legible.
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f", // original bright lime (the brand accent)
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// draculaPalette — the Dracula scheme (dracula.com): muted-violet surface, purple
// accent, high-chroma pink/green/cyan signals.
var draculaPalette = palette{
panel: "#282a36",
promptBg: "#383c4d",
line: "#363a4b",
line2: "#484c62",
ink: "#f8f8f2",
muted: "#b9bccb",
faint: "#a2a5b8",
faintest: "#9195ac",
accent: "#bd93f9",
green: "#50fa7b",
red: "#ff5555",
amber: "#ffb86c",
blue: "#8be9fd",
gitAdd: "#77c58c",
gitDel: "#d98d8d",
addBg: "#1c3b2a",
delBg: "#3a2026",
addBgWord: "#235035",
delBgWord: "#5e333b",
permBg: "#322a1e",
selBg: "#504482",
addInk: "#cbf2dd",
delInk: "#f4c9c9",
onAccent: "#000000",
cardRun: "#7c6aa6",
cardErr: "#98505a",
cardPerm: "#9a7c62",
}
// nordPalette — the Nord scheme (nordtheme.com): cool polar-night slate with a
// frost-blue accent and desaturated aurora signals.
var nordPalette = palette{
panel: "#3b4252",
promptBg: "#464f62",
line: "#434c5e",
line2: "#4c566a",
ink: "#eceff4",
muted: "#c8cfda",
faint: "#b4bdcb",
faintest: "#a5afc1",
accent: "#88c0d0",
green: "#a3be8c",
red: "#bf616a",
amber: "#d08770",
blue: "#81a1c1",
gitAdd: "#8ba077",
gitDel: "#b0757d",
addBg: "#37433a",
delBg: "#45383d",
addBgWord: "#456d46",
delBgWord: "#6d4650",
permBg: "#47413a",
selBg: "#40688a",
addInk: "#d6ecca",
delInk: "#f0c6cb",
onAccent: "#000000",
cardRun: "#4c6672",
cardErr: "#6b4a51",
cardPerm: "#6a564d",
}
// gruvboxPalette — Gruvbox dark (medium): warm retro browns with an olive-green
// accent and high-contrast cream ink.
var gruvboxPalette = palette{
panel: "#32302f",
promptBg: "#3c3836",
line: "#504945",
line2: "#665c54",
ink: "#ebdbb2",
muted: "#c9b99a",
faint: "#b7a78d",
faintest: "#a89984",
accent: "#8ec07c",
green: "#b8bb26",
red: "#fb4934",
amber: "#fabd2f",
blue: "#83a598",
gitAdd: "#98971a",
gitDel: "#cc241d",
addBg: "#2f3a29",
delBg: "#3b2b29",
addBgWord: "#3e5236",
delBgWord: "#593733",
permBg: "#38331e",
selBg: "#3d4e30",
addInk: "#c5e6b0",
delInk: "#f0c0bb",
onAccent: "#000000",
cardRun: "#6f8460",
cardErr: "#a5493d",
cardPerm: "#a5833a",
}
// tokyoNightPalette — Tokyo Night (storm): deep indigo surface, soft blue accent,
// cool high-contrast ink.
var tokyoNightPalette = palette{
panel: "#1e2030",
promptBg: "#2c3149",
line: "#262a3d",
line2: "#3b4261",
ink: "#c8d3f5",
muted: "#a9b1d0",
faint: "#9099b2",
faintest: "#838ba8",
accent: "#82aaff",
green: "#c3e88d",
red: "#ff757f",
amber: "#ffc777",
blue: "#86e1fc",
gitAdd: "#96bf7d",
gitDel: "#c77e85",
addBg: "#20303b",
delBg: "#37222c",
addBgWord: "#2b5a4a",
delBgWord: "#5c2e3a",
permBg: "#2a2419",
selBg: "#2a385b",
addInk: "#b8e4d3",
delInk: "#f3c4cb",
onAccent: "#000000",
cardRun: "#4b5d8b",
cardErr: "#7d4857",
cardPerm: "#7d6954",
}
// catppuccinPalette — Catppuccin Mocha: soft lavender surface with a mauve accent
// and pastel signals.
var catppuccinPalette = palette{
panel: "#1e1e2e",
promptBg: "#34364b",
line: "#313244",
line2: "#45475a",
ink: "#cdd6f4",
muted: "#a6adc8",
faint: "#9399b2",
faintest: "#83889f",
accent: "#cba6f7",
green: "#a6e3a1",
red: "#f38ba8",
amber: "#f9e2af",
blue: "#89b4fa",
gitAdd: "#8cbf8a",
gitDel: "#cc8a9b",
addBg: "#24312b",
delBg: "#3c2a32",
addBgWord: "#2f5140",
delBgWord: "#56333f",
permBg: "#29261b",
selBg: "#322e46",
addInk: "#c4ecd6",
delInk: "#f4cdd6",
onAccent: "#000000",
cardRun: "#7a6a99",
cardErr: "#8a5b72",
cardPerm: "#8d8274",
}
// oneDarkPalette — Atom One Dark: slate-gray surface with a blue accent.
var oneDarkPalette = palette{
panel: "#2e323b",
promptBg: "#3a3f4a",
line: "#393f4a",
line2: "#4b525f",
ink: "#abb2bf",
muted: "#a2a9b6",
faint: "#9aa1af",
faintest: "#969cab",
accent: "#61afef",
green: "#98c379",
red: "#e06c75",
amber: "#e5c07b",
blue: "#56b6c2",
gitAdd: "#82a06a",
gitDel: "#bd8087",
addBg: "#2c382b",
delBg: "#3a2d2f",
addBgWord: "#3d5a3a",
delBgWord: "#5c3e40",
permBg: "#3c3826",
selBg: "#354256",
addInk: "#cdeab3",
delInk: "#f0c3c7",
onAccent: "#000000",
cardRun: "#496c8c",
cardErr: "#7c515b",
cardPerm: "#7e735e",
}
// solarizedDarkPalette — Solarized Dark (Ethan Schoonover): the signature teal
// base03/base02 surface with a cyan accent and the fixed accent wheel.
var solarizedDarkPalette = palette{
panel: "#073642",
promptBg: "#0b3b46",
line: "#123f48",
line2: "#4b636c",
ink: "#cdd6d6",
muted: "#a9b3b3",
faint: "#9ba5a5",
faintest: "#929c9c",
accent: "#3bb3a6", // solarized cyan, brightened to clear AA on the lifted base02 panel
green: "#859900",
red: "#dc322f",
amber: "#b58900",
blue: "#268bd2",
gitAdd: "#93a05e",
gitDel: "#c67b71",
addBg: "#123f31",
delBg: "#45302e",
addBgWord: "#1e5c44",
delBgWord: "#6a3f3a",
permBg: "#2e2a18",
selBg: "#17505a",
addInk: "#c3ecd6",
delInk: "#f2cbc6",
onAccent: "#000000",
cardRun: "#1d6b6c",
cardErr: "#6d393d",
cardPerm: "#5b6028",
}
// rosePinePalette — Rosé Pine (main): muted rose-quartz base with a soft-rose
// accent and cool pine/foam signals.
var rosePinePalette = palette{
panel: "#1f1d2e",
promptBg: "#2f2b47",
line: "#2b2840",
line2: "#403d52",
ink: "#e0def4",
muted: "#a8a3c0",
faint: "#928ea9",
faintest: "#8985a0",
accent: "#ebbcba",
green: "#31748f",
red: "#eb6f92",
amber: "#f6c177",
blue: "#9ccfd8",
gitAdd: "#5e88a2",
gitDel: "#d589a7",
addBg: "#1f2d3a",
delBg: "#3a1f2d",
addBgWord: "#274b5e",
delBgWord: "#763a4f",
permBg: "#4a3e3d",
selBg: "#44415a",
addInk: "#cfe8ef",
delInk: "#f6c9cd",
onAccent: "#000000",
cardRun: "#7c6673",
cardErr: "#7c4662",
cardPerm: "#806857",
}
// everforestPalette — Everforest dark (medium): warm forest-gray surface with a
// sage-green accent.
var everforestPalette = palette{
panel: "#333c43",
promptBg: "#3d484d",
line: "#414b52",
line2: "#55636b",
ink: "#d3c6aa",
muted: "#b0bab0",
faint: "#a4aea3",
faintest: "#9ca99b",
accent: "#a7c080",
green: "#83c092",
red: "#e67e80",
amber: "#dbbc7f",
blue: "#7fbbb3",
gitAdd: "#8faa78",
gitDel: "#c08888",
addBg: "#2c3f37",
delBg: "#3e3234",
addBgWord: "#324e3b",
delBgWord: "#573a3c",
permBg: "#3c382d",
selBg: "#3b482e",
addInk: "#cfead0",
delInk: "#f4cfcd",
onAccent: "#000000",
cardRun: "#798b6b",
cardErr: "#9c676b",
cardPerm: "#96896b",
}
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
// terminal page, which Zero never paints) with near-black ink and an olive-lime
// accent that keeps the brand identity while clearing AA on the light panel. The
// muted/faint/faintest grays get progressively LIGHTER toward the surface; diff and
// permission tints are light surfaces. Replaces the old flat cool-gray light set
// whose muddy panel, sub-AA diff spans, and near-invisible selBg read as broken.
var lightPalette = palette{
panel: "#efebd4",
promptBg: "#e3ddc2",
line: "#d8d2bd",
line2: "#b7b199",
ink: "#22201a",
muted: "#4b5149",
faint: "#575e55",
faintest: "#636a61",
accent: "#54700a",
green: "#1e725c",
red: "#c02434",
amber: "#8a5f00",
blue: "#1f66c0",
gitAdd: "#3d6f46",
gitDel: "#a34a4a",
addBg: "#ddf0df",
delBg: "#f8dcdc",
addBgWord: "#a2daae",
delBgWord: "#f2b6b6",
permBg: "#f7ebc6",
selBg: "#d4e08f",
addInk: "#0c4026",
delInk: "#641a1d",
onAccent: "#ffffff",
cardRun: "#b0be7e",
cardErr: "#d8b0a8",
cardPerm: "#d6c496",
}
// solarizedLightPalette — Solarized Light: the base3/base2 cream surface with the
// same fixed accent wheel as Solarized Dark, dark-on-light.
var solarizedLightPalette = palette{
panel: "#eee8d5",
promptBg: "#e1d9be",
line: "#d8d1bc",
line2: "#c0b89e",
ink: "#304049",
muted: "#495b61",
faint: "#506469",
faintest: "#576b72",
accent: "#0c665c", // solarized cyan, darkened for AA on the cream panel (with white onAccent)
green: "#859900",
red: "#dc322f",
amber: "#7a5c00", // solarized yellow, darkened so white onAccent works on amber fills
blue: "#268bd2",
gitAdd: "#788d34",
gitDel: "#ac4f50",
addBg: "#dde8c6",
delBg: "#f1ddd2",
addBgWord: "#b9d488", // deepened so the changed span separates from addBg (sep 1.28)
delBgWord: "#edc4b4",
permBg: "#f0e6bd",
selBg: "#a6d6c4",
addInk: "#38480a",
delInk: "#6f1614",
onAccent: "#ffffff", // white — accent and amber are both dark on this light theme
cardRun: "#7fbaaf",
cardErr: "#d8837a",
cardPerm: "#c4ae63",
}
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
type themeEntry struct {
Name string
Label string
Palette palette
IsDark bool
}
// themeRegistry is the ordered source of truth for every selectable theme. Order is
// the picker order: all Dark themes first, then all Light, with the brand
// dark/light built-ins leading their groups. themeModes (theme_select.go) prepends
// `auto` to this. Append here to add a theme — nothing else needs editing.
var themeRegistry = []themeEntry{
{Name: "dark", Label: "dark", Palette: darkPalette, IsDark: true},
{Name: "dracula", Label: "Dracula", Palette: draculaPalette, IsDark: true},
{Name: "nord", Label: "Nord", Palette: nordPalette, IsDark: true},
{Name: "gruvbox", Label: "Gruvbox", Palette: gruvboxPalette, IsDark: true},
{Name: "tokyo-night", Label: "Tokyo Night", Palette: tokyoNightPalette, IsDark: true},
{Name: "catppuccin", Label: "Catppuccin", Palette: catppuccinPalette, IsDark: true},
{Name: "one-dark", Label: "One Dark", Palette: oneDarkPalette, IsDark: true},
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
}
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
// var initializer (not init()) so it is ready before themeModes' package-var
// initializer calls themeNames().
var themeByName = func() map[string]themeEntry {
byName := make(map[string]themeEntry, len(themeRegistry))
for _, entry := range themeRegistry {
byName[entry.Name] = entry
}
return byName
}()
// lookupTheme resolves a theme name (case/space-insensitive) to its entry.
func lookupTheme(name string) (themeEntry, bool) {
entry, ok := themeByName[strings.ToLower(strings.TrimSpace(name))]
return entry, ok
}
// themeNames returns every registered theme name in registry (picker) order.
func themeNames() []string {
names := make([]string, len(themeRegistry))
for index, entry := range themeRegistry {
names[index] = entry.Name
}
return names
}
-340
View File
@@ -1,340 +0,0 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// A committed theme is written to user config and reloaded at startup (via
// Options.SavedTheme -> resolveThemeMode), so a /theme choice survives restart.
func TestThemeChoicePersistsAcrossRestart(t *testing.T) {
defer applyTheme(themeDark, true)
cfgPath := filepath.Join(t.TempDir(), "config.json")
// First session: pick a color theme via the /theme command (same commit path the
// picker uses).
m := newModel(context.Background(), Options{UserConfigPath: cfgPath})
m, _ = m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
t.Fatalf("themeMode = %q, want dracula", m.themeMode)
}
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("theme commit should have written config: %v", err)
}
var cfg struct {
Preferences struct {
Theme string `json:"theme"`
} `json:"preferences"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("config is not valid JSON: %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("preferences.theme = %q, want dracula", cfg.Preferences.Theme)
}
// Second session: the persisted theme seeds startup (no flag / env).
restarted := newModel(context.Background(), Options{UserConfigPath: cfgPath, SavedTheme: "dracula"})
if restarted.themeMode != themeMode("dracula") {
t.Fatalf("restarted themeMode = %q, want dracula (from saved config)", restarted.themeMode)
}
}
// resolveThemeMode precedence: flag > ZERO_THEME > persisted config > auto.
func TestResolveThemeModeConfigFallback(t *testing.T) {
if got := resolveThemeMode("", "", "nord"); got != themeMode("nord") {
t.Errorf("saved-only = %q, want nord", got)
}
if got := resolveThemeMode("dracula", "", "nord"); got != themeMode("dracula") {
t.Errorf("flag should beat saved: got %q, want dracula", got)
}
if got := resolveThemeMode("", "gruvbox", "nord"); got != themeMode("gruvbox") {
t.Errorf("env should beat saved: got %q, want gruvbox", got)
}
if got := resolveThemeMode("", "", "bogus-theme"); got != themeAuto {
t.Errorf("unknown saved theme should fall back to auto, got %q", got)
}
}
// themePickerRowIndex finds the picker row whose Value is name (index-independent,
// so tests survive registry reordering / additions).
func themePickerRowIndex(t *testing.T, p *commandPicker, name string) int {
t.Helper()
for i, item := range p.items {
if item.Value == name {
return i
}
}
t.Fatalf("theme picker has no row for %q", name)
return -1
}
// assertPreviewMatchesSelection checks the live palette equals the palette of the
// currently highlighted theme row (the auto row has no palette of its own).
func assertPreviewMatchesSelection(t *testing.T, m model) {
t.Helper()
sel := m.picker.items[m.picker.selected]
entry, ok := lookupTheme(sel.Value)
if !ok {
return
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, entry.Palette.ink) {
t.Errorf("preview of %q did not apply its palette (ink mismatch)", sel.Value)
}
}
// Bare `/theme` opens the popup picker (like /model and /effort) with one row per
// theme mode, preselecting the active preference. An explicit `/theme <mode>` must
// keep taking the text path so scripts and the existing state view still work.
func TestThemePickerOpensOnBareTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeLight
m.input.SetValue("/theme")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("opening the theme picker should not emit a cmd, got %T", cmd)
}
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
}
if len(m.picker.items) != len(themeModes) {
t.Fatalf("picker has %d items, want %d", len(m.picker.items), len(themeModes))
}
for i, want := range themeModes {
if m.picker.items[i].Value != want {
t.Errorf("item %d value = %q, want %q", i, m.picker.items[i].Value, want)
}
}
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Errorf("preselected value = %q, want the active mode %q", got, themeLight)
}
}
// An explicit mode argument runs the text handler directly and never opens the
// popup — guards the dispatch branch and keeps /theme <mode> scriptable.
func TestThemeArgSkipsPicker(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme dark")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatalf("explicit /theme dark must not open a picker, got %#v", m.picker)
}
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
}
}
// Moving the cursor live-previews each palette: the global zeroTheme swaps as the
// selection changes, so the whole UI (and the overlay) repaints in the hovered
// theme without committing the preference.
func TestThemePickerPreviewsOnMove(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Each Down moves to the next theme and live-applies its palette; Up follows back.
for i := 0; i < 3; i++ {
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
}
updated, _ = m.Update(testKey(tea.KeyUp))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
// Preview must not have written the committed preference.
if m.themeMode != themeDark {
t.Errorf("preview mutated the committed mode to %q", m.themeMode)
}
}
// Enter on a fixed mode commits it: the palette stays applied, the preference is
// recorded, the popup closes, and the switch is noted in the transcript.
func TestThemePickerCommitAppliesAndRecords(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Choose a color theme by locating its row (index-independent), then confirm.
m.picker.selected = themePickerRowIndex(t, m.picker, "dracula")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("committing a fixed theme should not emit a cmd, got %T", cmd)
}
if m.picker != nil {
t.Fatal("committing should close the picker")
}
if m.themeMode != themeMode("dracula") {
t.Fatalf("committed mode = %q, want dracula", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, draculaPalette.ink) {
t.Error("commit did not leave the dracula palette applied")
}
if !transcriptContains(m.transcript, "active theme") {
t.Errorf("commit should record the switch in the transcript:\n%s", transcriptText(m.transcript))
}
}
// Committing `auto` re-probes the terminal background (like the text dispatch) so
// the palette re-detects light/dark instead of reusing the preview's reading.
func TestThemePickerAutoCommitReprobesBackground(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
m.picker.selected = 0 // "auto"
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.themeMode != themeAuto {
t.Fatalf("committed mode = %q, want auto", m.themeMode)
}
if cmd == nil {
t.Fatal("committing auto must return a background re-probe cmd")
}
if reflect.ValueOf(cmd).Pointer() != reflect.ValueOf(tea.RequestBackgroundColor).Pointer() {
t.Error("committing auto must return tea.RequestBackgroundColor")
}
}
// Typing to filter the list re-previews the newly-highlighted mode, so the applied
// palette never diverges from the row the popup points at. Backspacing back to an
// empty query re-previews the (restored) selection too.
func TestThemePickerFilterRePreviews(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview light by moving, then filter to "light" — the applied palette must
// track the filtered highlight (still light here), not lag behind.
updated, _ = m.Update(testKey(tea.KeyDown)) // dark -> light preview
m = updated.(model)
updated, _ = m.Update(testKeyText("l"))
m = updated.(model)
updated, _ = m.Update(testKeyText("i"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Fatalf("filter 'li' should highlight light, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("filtering to light did not keep the light palette previewed")
}
// Re-filter to "dark": highlight and preview must both switch to dark.
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKeyText("d"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeDark) {
t.Fatalf("filter 'd' should highlight dark, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("filtering to dark did not re-preview the dark palette")
}
}
// A filter that matches nothing must not strand the last preview: the palette
// falls back to the committed theme, and committing (Enter on the empty list) is a
// clean no-op that leaves the committed mode and palette in agreement.
func TestThemePickerEmptyFilterRestoresCommitted(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then type a char that matches no theme name
// ('q' appears in none) -> the list empties.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
updated, _ = m.Update(testKeyText("q"))
m = updated.(model)
if len(m.picker.items) != 0 {
t.Fatalf("filter 'q' should match no theme, got %d items", len(m.picker.items))
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("an empty filter should restore the committed dark palette, not keep the preview")
}
// Enter on the empty list closes the picker without changing the committed
// preference, and leaves the palette matching it.
updated, _ = m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatal("Enter on an empty filter should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("empty-list commit must not change the mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("after an empty-list commit the palette must match the committed dark mode")
}
}
// Esc dismisses the picker without choosing and restores the committed palette,
// undoing whatever the live preview applied.
func TestThemePickerEscRestoresCommittedTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then bail out.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r == mustR(t, darkPalette.ink) {
t.Fatal("expected a non-dark preview to be applied before Esc")
}
updated, _ = m.Update(testKey(tea.KeyEsc))
m = updated.(model)
if m.picker != nil {
t.Fatal("Esc should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("Esc must not change the committed mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("Esc did not restore the committed dark palette")
}
}
+28 -98
View File
@@ -1,10 +1,6 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/config"
)
import "strings"
// themeMode is the operator's palette preference.
type themeMode string
@@ -15,49 +11,34 @@ const (
themeLight themeMode = "light"
)
// themeModes lists the values /theme accepts, in picker order: `auto` first, then
// every registered theme (theme_palettes.go). It is the single ordered source
// feeding both the picker and the /theme state list — adding a registry entry
// extends it automatically.
var themeModes = append([]string{string(themeAuto)}, themeNames()...)
// themeModes lists the values /theme accepts.
var themeModes = []string{string(themeAuto), string(themeDark), string(themeLight)}
// resolveThemeMode picks the first accepted preference from candidates in
// precedence order — the caller passes them highest-first: the --theme flag, then
// ZERO_THEME, then the persisted config theme. A value is accepted if it is `auto`
// or names a registered theme; unrecognized/blank values are skipped, and an empty
// list (or all-unrecognized) falls back to auto.
func resolveThemeMode(candidates ...string) themeMode {
for _, v := range candidates {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
continue
}
if s == string(themeAuto) {
// resolveThemeMode picks the preference in precedence order: an explicit value
// (the --theme flag, threaded via Options.Theme), then ZERO_THEME, then auto.
func resolveThemeMode(flagValue, envValue string) themeMode {
for _, v := range []string{flagValue, envValue} {
switch strings.ToLower(strings.TrimSpace(v)) {
case "dark":
return themeDark
case "light":
return themeLight
case "auto":
return themeAuto
}
if _, ok := lookupTheme(s); ok {
return themeMode(s)
}
}
return themeAuto
}
// validThemeMode reports whether s names a theme mode (for /theme validation):
// `auto` or any registered theme.
// validThemeMode reports whether s names a theme mode (for /theme validation).
func validThemeMode(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
if s == string(themeAuto) {
switch themeMode(strings.ToLower(strings.TrimSpace(s))) {
case themeAuto, themeDark, themeLight:
return true
}
_, ok := lookupTheme(s)
return ok
return false
}
// ValidThemeArg reports whether s is an acceptable --theme / ZERO_THEME value
// (`auto` or a registered theme name). Exported so the CLI flag validator shares
// this one source of truth instead of hardcoding the theme list.
func ValidThemeArg(s string) bool { return validThemeMode(s) }
// applyTheme swaps the active palette (zeroTheme) and the globals derived from it
// — the streaming-fade ramp and the static render cache — so a switch repaints
// every subsequent render. For themeAuto it resolves to dark/light from
@@ -72,13 +53,12 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
resolved = themeLight
}
}
// Resolve the (now concrete) mode to its registered palette; an unknown name
// falls back to the dark built-in so a bad value can never leave zeroTheme unset.
entry, ok := lookupTheme(string(resolved))
if !ok {
entry, _ = lookupTheme(string(themeDark))
switch resolved {
case themeLight:
zeroTheme = buildTheme(lightPalette)
default:
zeroTheme = buildTheme(darkPalette)
}
zeroTheme = buildTheme(entry.Palette)
rebuildStreamingFadePalette()
if defaultRenderCache != nil {
defaultRenderCache.clear() // old-palette entries must not be reused
@@ -86,45 +66,15 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
return resolved
}
// previewSelectedTheme makes the live palette match the theme picker's current
// state: the highlighted mode when a row is selectable, or the committed
// m.themeMode when the filter matches nothing. Called on every change to the
// picker's selection or filter (arrow/wheel moves, mouse, and query typing) so the
// whole UI — and the overlay itself — always renders the mode the popup points at,
// and never strands on a stale preview. It only swaps the global zeroTheme via
// applyTheme; m.themeMode keeps the committed preference so Esc can restore it. A
// no-op unless a theme picker is open. Runs on the Update goroutine, like every
// zeroTheme access.
func (m model) previewSelectedTheme() {
if m.picker == nil || m.picker.kind != pickerTheme {
return
}
if item, ok := m.picker.current(); ok {
applyTheme(themeMode(item.Value), m.hasDarkBg)
return
}
// No selectable row (the filter matched nothing): fall back to the committed
// theme rather than leaving the previous preview applied.
m.restoreCommittedTheme()
}
// restoreCommittedTheme re-applies m.themeMode after a /theme preview is dismissed
// without choosing (Esc). Preview never wrote m.themeMode, so it still holds the
// real preference; re-applying repaints back to it.
func (m model) restoreCommittedTheme() {
applyTheme(m.themeMode, m.hasDarkBg)
}
// handleThemeCommand implements /theme [name]: `list` shows state, a registered
// theme name (or `auto`) switches the active palette live. Bare `/theme` opens the
// picker at the dispatch layer, so it never reaches here empty. Mirrors handleStyleCommand.
// handleThemeCommand implements /theme [auto|dark|light]: no arg shows state, a
// valid mode switches the active palette live. Mirrors handleStyleCommand.
func (m model) handleThemeCommand(args string) (model, string) {
arg := strings.ToLower(strings.TrimSpace(args))
if arg == "" || arg == "list" {
return m, m.themeStateText()
}
if !validThemeMode(arg) {
return m, "Theme\nUnknown theme: " + arg + " (use /theme with no argument to pick from the list)"
return m, "Theme\nUnknown theme: " + arg + " (use auto, dark, or light)"
}
m.themeMode = themeMode(arg)
resolved := applyTheme(m.themeMode, m.hasDarkBg)
@@ -132,31 +82,11 @@ func (m model) handleThemeCommand(args string) (model, string) {
if m.themeMode == themeAuto {
active = "auto (" + string(resolved) + ")"
}
lines := []string{
return m, strings.Join([]string{
"Theme",
"active theme: " + active,
"Already-printed scrollback keeps its previous colors; new output uses the new theme.",
}
// Commit path (both /theme <name> and the picker route through here): persist the
// choice so it survives restart. Previews call applyTheme directly and never reach here.
if note := m.persistThemePreference(); note != "" {
lines = append(lines, note)
}
return m, strings.Join(lines, "\n")
}
// persistThemePreference writes the committed theme to user config so it is applied
// again at startup (via Options.SavedTheme -> resolveThemeMode). Best-effort: returns
// a short note to surface on failure, or "" on success / when there is no config
// path (e.g. tests). Never called from the live-preview path.
func (m model) persistThemePreference() string {
if strings.TrimSpace(m.userConfigPath) == "" {
return ""
}
if _, err := config.SetTheme(m.userConfigPath, string(m.themeMode)); err != nil {
return "note: could not save theme preference (" + err.Error() + ")"
}
return ""
}, "\n")
}
// themeStateText renders the /theme state view.
@@ -179,6 +109,6 @@ func (m model) themeStateText() string {
"available: " + strings.Join(themeModes, ", "),
},
}},
Hints: []string{"run /theme with no argument to open the picker, or /theme <name> to switch directly"},
Hints: []string{"use /theme <auto|dark|light> to switch this TUI session"},
})
}
+26 -55
View File
@@ -52,19 +52,24 @@ func wcagRatio(t *testing.T, fg, bg string) float64 {
// The word-level diff's brighter changed-span band must keep its text AA-readable
// and stay clearly distinct from the base add/del band, on both themes.
func TestDiffWordSpanContrast(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.addInk, c.pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", c.name, r)
}
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
if r := wcagRatio(t, c.pal.delInk, c.pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", c.name, r)
}
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
if sep := wcagRatio(t, c.pal.addBgWord, c.pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
}
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
if sep := wcagRatio(t, c.pal.delBgWord, c.pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
}
}
}
@@ -73,52 +78,18 @@ func TestDiffWordSpanContrast(t *testing.T) {
// AND keep its label readable. Guards the regression this fixes: the light
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.ink, c.pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", c.name, r)
}
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
}
}
}
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
// for light themes). Guards that a newly-added color palette can't ship illegible.
func TestAllThemesContrastAndHierarchy(t *testing.T) {
for _, entry := range themeRegistry {
pal := entry.Palette
for _, tok := range []struct {
name string
fg string
}{
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
{"faintest", pal.faintest}, {"accent", pal.accent},
} {
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
}
}
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
}
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
// rises toward the surface on light themes, falls on dark themes.
chain := []float64{
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
relLum(t, pal.faintest), relLum(t, pal.panel),
}
for i := 1; i < len(chain); i++ {
ok := chain[i] > chain[i-1]
if entry.IsDark {
ok = chain[i] < chain[i-1]
}
if !ok {
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
}
if sep := wcagRatio(t, c.pal.selBg, c.pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", c.name, sep)
}
}
}
-5
View File
@@ -5,11 +5,6 @@ import "strings"
func (m model) toggleDetailedTranscript() model {
m.transcriptDetailed = !m.transcriptDetailed
m.clearSuggestions()
if m.picker != nil && m.picker.kind == pickerTheme {
// Dropping a theme picker mid-preview must undo the live palette, or the
// previewed theme would stick while m.themeMode still holds the old one.
m.restoreCommittedTheme()
}
m.picker = nil
return m
}
+3 -12
View File
@@ -201,9 +201,6 @@ func (m model) statusLine(width int) string {
if m.exitConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
}
if m.cancelConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
}
return fitStyledLine(left, width)
}
@@ -213,8 +210,6 @@ func (m model) statusLine(width int) string {
}
if m.exitConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}
@@ -223,19 +218,15 @@ func (m model) statusLine(width int) string {
// Context-fill gauge: surface it down to the narrow tier (where it matters
// most), but skip it when the context sidebar is already showing the % so the
// figure isn't duplicated.
gaugeShown := false
if tier >= tierNarrow && !m.sidebarActive() {
if gauge := m.contextWindowSegment(); gauge != "" {
rightGroups = append(rightGroups, gauge)
gaugeShown = true
}
}
// The sidebar pins the token readout at its floor, and the gauge's "used"
// figure is the exact same number (both read latestUsageTokens) — either
// one showing means the plain usage segment must drop its own token count
// to just the cost, or the count renders twice side by side.
// The sidebar pins the token readout at its floor, so when it's open keep
// only the cost here — otherwise the token figure shows on both sides.
usage := m.usageStatusSegment()
if m.sidebarActive() || gaugeShown {
if m.sidebarActive() {
usage = m.usageCostSegment()
}
if usage != "" {
-40
View File
@@ -106,46 +106,6 @@ func TestQuietGenerationHint(t *testing.T) {
}
}
// TestQuietGenerationHintEscalatesPastHalfIdleTimeout: a heartbeating-but-
// silent stream (chatgpt/gpt-5.x, ollama reasoning models — see
// providerio.ErrStreamStalled) and a genuinely healthy-but-slow one look
// identical under the plain "still generating… Xs" cue — the ticking number
// is the only signal, whether real content is still coming or nothing ever
// will. Past half the provider's idle timeout the cue must say so explicitly
// and name when Zero's own content-stall watchdog will act, rather than
// leaving the user to guess whether this is a hang.
func TestQuietGenerationHintEscalatesPastHalfIdleTimeout(t *testing.T) {
// 30s idle timeout: half (15s) sits comfortably above quietWorkingHint (8s),
// leaving a clean window to observe the plain cue before it escalates.
t.Setenv("ZERO_STREAM_IDLE_TIMEOUT", "30s")
base := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
m := model{now: func() time.Time { return base }}
m.activeRunID = 7
m.turnStartedAt = base
// Quiet past quietWorkingHint but under half the (30s) idle timeout -> the
// plain cue, same as TestQuietGenerationHint's normal case.
m.lastStreamActivity = base.Add(-10 * time.Second)
got := m.quietGenerationHint()
if !strings.Contains(got, "still generating") {
t.Fatalf("under half the idle timeout: want the plain cue, got %q", got)
}
if strings.Contains(got, "auto-recover") {
t.Fatalf("under half the idle timeout: should not have escalated yet, got %q", got)
}
// Quiet for >= half the idle timeout -> escalates to name the watchdog and
// its ceiling (providerio.ContentStallTimeout(30s) = 30s*1.2 = 36s here).
m.lastStreamActivity = base.Add(-15 * time.Second)
got = m.quietGenerationHint()
if !strings.Contains(got, "unusually quiet") || !strings.Contains(got, "auto-recover") {
t.Fatalf("past half the idle timeout: want an escalated cue naming the watchdog, got %q", got)
}
if !strings.Contains(got, "36s") {
t.Fatalf("escalated cue should name the content-stall ceiling (ContentStallTimeout(30s) = 36s), got %q", got)
}
}
// TestQuietHintHiddenWhenSidebarShowsIt: when the context sidebar is up (it
// carries the "generating…" pulse in ACTIVITY), the working line must NOT also
// show the hint — it appears in exactly one place.