Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 9e98c17dad fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
  documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
  accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
  directly instead of a raw substring, so a wrong-key regression fails.
2026-07-01 15:39:53 +05:30
Vasanthdev2004 3db4aa90f4 Merge origin/main into feat/theme-catalog
Resolves the internal/tui/theme.go conflict: darkPalette/lightPalette now live in
theme_palettes.go on this branch, and the accent restore from #351 (#759523 ->
#caff3f, the original bright lime) is carried into that palette's darkPalette.
streaming_fade_test.go (which pins the #caff3f accent endpoint) and the
exec_command output-buffer cap from #353 merge in unchanged.
2026-07-01 15:32:00 +05:30
Vasanthdev2004 d0ee963f76 feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds 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 — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.

Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.

The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
2026-07-01 15:24:57 +05:30
29 changed files with 142 additions and 1108 deletions

No files matched your search

-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,
})
}
-3
View File
@@ -43,9 +43,6 @@ func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenRes
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
+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{
@@ -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")
}
}
-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
-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)"},
},
+89 -192
View File
@@ -40,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
@@ -57,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).
@@ -306,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
@@ -348,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
@@ -382,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
@@ -675,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"), 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,
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
@@ -790,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 {
@@ -816,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
@@ -899,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).
@@ -985,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
@@ -1037,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 {
@@ -1066,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()
@@ -1140,24 +1061,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 {
@@ -1713,7 +1621,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
@@ -2015,14 +1922,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
}
@@ -3395,13 +3294,12 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
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)
}
@@ -3431,7 +3329,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
return m, tea.RequestBackgroundColor
}
}
return m, cmd
return m, nil
}
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
@@ -3955,8 +3853,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) {
-9
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
+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 ""
+26 -27
View File
@@ -20,33 +20,32 @@ 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
-40
View File
@@ -565,46 +565,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) ||
-97
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"
)
@@ -879,100 +878,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")
}
}
-24
View File
@@ -166,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
@@ -1233,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()
+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
+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 != "" {