Surface which models have a fast variant: a dim "f" badge on the
active-model chip, the /model picker, and the /provider wizard, plus a
focused-row "fast: <name>" hint (the pickers are keyboard-driven, so the
selected row is the reveal). All read Registry.FastVariant, so coverage
tracks the catalog automatically.
Give /fast a distinctive voice and stop it cluttering the transcript:
reword its messages and show the result as a transient toast pill in the
row above the composer, auto-dismissed after 3s (seq-gated, same pattern
as the copy-status flash).
Add a FastVariantID field to ModelEntry (mirroring UpgradeTargetID),
populated in the catalog for the four same-family base/fast pairs and
validated at registry build. Refactor the /fast helpers to derive both
directions from this field instead of a hardcoded table, and add a
HasFastVariant predicate for UI callers.
Memoize DefaultRegistry with sync.OnceValues: the default catalog is
static, every accessor returns clones, and a built registry is never
mutated, so per-frame TUI callers no longer re-clone entries and
recompile fuzzy-match regexes each render.
/fast [on|off] switches the session model between its base and a faster
same-family variant (e.g. Sonnet<->Haiku, GPT-4.1<->GPT-4.1-mini). State is
DERIVED from the model identity, never stored: the session is "in fast mode"
iff the current model has a base variant.
- modelregistry: a config-driven base<->fast mapping table (fastVariantPairs)
plus Registry.FastVariant / BaseVariant, mirroring the existing UpgradeTarget
pattern. Adding a pair is a one-line data change — no logic edit. Populated
for the models with a meaningful fast variant (Sonnet/Haiku, GPT-4.1/mini,
GPT-4o/mini, Gemini-2.5 Pro/Flash), guarded by tests that every id resolves
and no id is both a base and a fast.
- tui: /fast registered in commandDefinitions (model group) and dispatched via
a pure planFastCommand decision + handleFastCommand, which delegates the real
switch to handleModelCommand (provider rebuild, compaction guard, persistence)
rather than mutating state directly. Notices use the existing appendSystem
mechanism.
- Tests: table-driven over every case — invalid arg, no model set, no-op both
ways, no fast variant, switch to fast (⚡) / back to base, switch-error
surfaced, and the variant helpers.
Gate: make build / go vet ./... / go test ./... -race / make lint all green.
* polish: cut per-turn overhead, stabilize cache, add /new
Context economy
- Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync).
- System prompt: kill the "ELABORATE summary" directive; scale summary to
the work. Comment discipline moved into the core prompt for all model
families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it.
- Token-budget regression test ratchets the fixed per-turn overhead (base
prompt + eager tool schemas) using the runtime's own estimator, so silent
prompt/schema growth fails CI.
Response quality
- defaultResponseStyle balanced->concise. Reversible via /style.
Tool discovery
- <available_skills> block in the system prompt lists installed skills
(name + one-line description) so the model loads the right one on the
first call instead of guessing and reading the failure. Budget-capped at
640 bytes with an "...and N more" fallback; renders nothing when no
skills are installed (byte-identical to prior prompt).
Session lifecycle
- /new starts a fresh session in place; previous session stays on disk and
is named in the confirmation for /resume. Guards against mid-run reset.
- /clear now notes that context persists and points to /new.
Performance & caching
- partitionTools no longer alpha-sorts the whole tools array every turn,
which inserted a loaded deferred tool mid-array and shifted the cached
prefix on every load. It now builds append-only regions
[eager sorted] + [tool_search] + [loaded deferred sorted] so a
mid-session load grows the tail instead of busting the eager prefix.
Regression test asserts the eager block precedes any loaded tool.
- /context now shows cache hit rate (flags low rate over >5 turns as a
possible unstable prefix) and rolling average turn latency with
time-to-first-token. Both reset on /new.
Measured baselines (auto-mode, no workspace): base prompt 3162 tokens,
eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above.
Deliberately deferred (need a decision): inverted safety-policy rewrite
(sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch
deferral (activation-semantics blocker), aggressive prompt slim.
* fix(tui): clear staged attachments + queued message on /new
CodeRabbit: startNewSession reset conversation/usage/latency state but not
the staged input (pendingImages, pendingImageLabels, pendingDocuments,
queuedMessage), which is only consumed at prompt-submit. Without clearing it,
the fresh session's first prompt silently inherited the previous session's
images/documents/queued text. Test now stages all four and asserts they clear.
* feat(tui): add search/filter to provider picker in setup wizard
Add a search input to the provider step of the setup wizard, matching
the existing model picker search UX. Typing filters the provider list
in real-time via case-insensitive substring matching on name, ID, and
aliases.
Fixes#392
* fix(tui): resolve provider index before clearing search on advance
Clearing providerSearch before using currentProvider() caused the wrong
provider to be selected — selectedProvider is an index into the filtered
slice, and clearing the search swaps to the full unfiltered list.
Also update placeholder to reflect that search matches ID and aliases.
* fix(tui): guard empty-filter Enter and add provider search tests
- Enter on an empty provider filter is now a no-op instead of advancing
with the wrong provider (first full-list entry)
- Add table tests for filteredProviders/name/ID/alias matching
- Add tests for selectedProvider reset on edit, empty-match noop,
Ctrl+U clear restores full list, and filtered advance selection
Addresses review feedback from @Vasanthdev2004 and @gnanam1990 on PR #400
* feat(update): add zero upgrade command to apply self-updates
Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:
- `zero update --apply` / `zero upgrade` download, sha256-verify, and
install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
checksum, extract it (zip-slip guarded), and atomically replace the
running binary plus any pre-existing optional sandbox helpers. On
Windows, the running exe is renamed aside (NTFS allows this even
while it's executing) since it can't be overwritten directly; the
leftover ".old" file is cleaned up on a later invocation.
Closes#459.
* fix(update): correct macOS expectations in apply test
The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.
* fix(update): address CodeRabbit review findings on zero upgrade
- Reject --target when combined with --apply/upgrade: applying installs
onto the current machine, so a mismatched --target could replace the
running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
from the release-metadata check timeout, so a stalled connection
can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
of swallowing it, so a botched install is reported rather than
silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
so a leftover Windows ".old" file isn't stuck waiting for a future
upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
ApplyResult (surfaced in text and JSON output) instead of discarding
them silently.
* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI
TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.
Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
* fix(agent): reject a malformed additional_permissions payload before prompting
A shell tool call that sets sandbox_permissions: with_additional_permissions
but omits (or malforms) the additional_permissions payload was accepted into
the normal permission-prompt flow. buildPermissionEvent silently swallowed
the validation error while rendering the prompt's scope text, so the user
saw an ordinary-looking request, approved it, and only then hit a hard
denial from the same validation running again at grant time (loop.go's
grantInlineAdditionalPermissions), with the confusing message "failed to
grant session additional permissions: missing additional_permissions".
A malformed payload can never succeed regardless of the user's decision, so
run the same check (inlineAdditionalPermissionsProfile, already the single
source of truth for this shape) up front, before any prompt, and return a
plain tool error the driving model can see and retry from. This also covers
the mismatched case (additional_permissions present without the flag).
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(agent): make the additional_permissions rejection message actionable
The rejection added in the previous commit correctly stops a malformed
sandbox_permissions: with_additional_permissions call before any prompt,
but its message ("missing additional_permissions; provide at least one of
network or file_system") gave a model nothing concrete to correct itself
with. Observed in practice: a weaker tool-calling model (xai/grok-build-0.1)
kept re-attaching sandbox_permissions out of habit from an earlier, correct
gh/git call onto later commands that didn't need it (network was already
open via a session-scoped grant), hit this same rejection 4 times in a row
with no change in its retry, and gave up.
Both messages (missing payload, and empty-but-present payload) now include
a concrete, schema-accurate example - {"network": {"enabled": true}} or
{"file_system": {"write": ["/path"]}} - and the missing-payload message
adds the other half of the fix: if the command doesn't actually need
elevated permissions, omit sandbox_permissions entirely and retry.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
createSecretFile's secret-lock loop only retried on os.ErrExist. On Windows a
concurrent holder's `defer os.Remove(lockPath)` leaves the lock file in a
"delete pending" state, so a racer's O_EXCL create returns ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist — which fell through to a hard
"create token secret lock: Access is denied" error instead of retrying. Mirrors
the identical handling already present in acquireFileLock (lock.go, from #261).
Fixes the flaky Windows-CI failure TestLoadOrCreateSecretConcurrentConverges
that was intermittently red-flagging every PR's Windows smoke job.
Verified on macOS: build/vet/gofmt clean, oauth package green, concurrent-
converge test passes under -race (5x). The Windows delete-pending path can't be
reproduced off Windows, so the definitive proof is the Windows CI on this PR.
* fix: allow non-TLS connections to private-network provider endpoints
The provider wizard and health probe rejected `http://` endpoints with a
non-loopback host, so users could not configure a local-network provider
like llama.cpp on another LAN machine (e.g. `http://192.168.1.100:8080/v1`).
Only loopback was relaxed.
Expand the "loopback-only" checks to also cover private-network addresses
(192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) via `netip.Addr.IsPrivate()`:
- `providerhealth.endpointIsLoopback` → `endpointIsLoopbackOrPrivate`
(also returns true for private IP literals).
- `providerhealth.allowLoopback` → `allowLoopbackOrPrivate` — the flag
now relaxes both loopback and private addresses in `validateEndpoint`
and `safeDialContext`.
- `providerWizardIsLoopbackHost` in the TUI wizard now accepts private
IPs, so `http://192.168.x.y` passes the endpoint URL validation.
- `providerProfileIsLocal` in headless setup recognizes private-network
IPs as local (skip API key for keyless LAN providers).
- `baseURLIsLoopback` in app.go recognizes private-network IPs for
credential-requirement logic.
Link-local (169.254.x.x), cloud metadata, and other special ranges
remain blocked. Redirects to any local/private address remain blocked
regardless of the flag (SSRF protection for 3xx targets is unchanged).
Fixes#431
* chore: fix gofmt indentation in connectivity client test
* fix(auth): route `zero auth login chatgpt` to the dedicated ChatGPT flow
`zero auth login chatgpt` went through the generic OAuth manager loopback
path, which builds a random-port http://127.0.0.1:<port>/callback
redirect_uri with no extra authorize params. OpenAI's ChatGPT client
registration requires exactly http://localhost:1455/auth/callback plus the
id_token_add_organizations, codex_cli_simplified_flow, and originator
params, so the authorize endpoint rejected the request with an
"Authentication Error" before any browser interaction.
Route the chatgpt provider to the existing dedicated ChatGPT login
(provideroauth.ChatGPTLogin), which already binds the fixed port, uses the
localhost host, the /auth/callback path, and sends the required params.
`zero auth login chatgpt` now behaves identically to `zero auth chatgpt`.
--device is rejected with a clear message since ChatGPT has no device-code
path.
Fixes#430
* fix(auth): reject --scope on ChatGPT login instead of silently dropping it
`zero auth login chatgpt --scope <x>` reached the dedicated ChatGPT flow,
which ignores the generic manager's ExtraScopes — the scopes were silently
dropped while --device was explicitly rejected. Reject --scope up front
with a clear message, since the Codex client registration pins a fixed
scope set (incl. api.connectors.*) that custom scopes would risk dropping.
Addresses CodeRabbit review on #443.
normalizeProfileForProvider rewrote profile.Name to the catalog provider.ID
whenever normalizeIdentity was true — which includes any first-party provider
whose saved baseURL matches the catalog default. The credential store is keyed
by profile.Name, and this rewrite runs before profileWithCredential, so the
stored-key lookup missed and the rebuilt provider went out with no key: a 401 on
every /model switch for a profile named differently from its catalog id (e.g.
"my-openai").
Only canonicalize an empty or generic placeholder name now; a real user-provided
name is preserved. CatalogID canonicalization is unchanged (it is not the store
key). Adds regression tests covering a custom-named first-party profile surviving
normalization and placeholder names still canonicalizing.
Fixes#440
* fix(sandbox): gate /tmp test assertions on GOOS, not path existence
TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots and
TestNewScopeNormalizesAndValidatesExtraRoots guard their /tmp assertion with
pathExists("/tmp"), intending to skip it on Windows. But Go resolves the bare
path /tmp against the current drive on Windows, so on any host where C:\tmp
exists the guard passes while the product code (correctly, by design - see
defaultTempWriteRootCandidatesForGOOS) uses %TEMP%/%TMP% instead, and both
tests fail. Gate the assertion on runtime.GOOS != "windows" as well; POSIX
behavior is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* chore: retrigger CI
The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth, unrelated to this change. Retriggering to confirm it was a flake.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Make shortcuts reconfigurable via the config file.
* Use sidebarToggleAllowed. Fix fmt errors.
* Fix: combination matching superset.
* Remove non-default shortcut combination from the comment.
* feat: add --auto flag to changes commit for LLM-generated commit messages
* fix: address CodeRabbit review suggestions on auto-commit message generation
* fix: resolve auto-commit message and flag validation tests
* fix: add 60s timeout for LLM call in --auto and surface --dry-run in help
Addresses gnanam1990 non-blocking feedback:
- Wrap provider call with context.WithTimeout (60s) to prevent hung provider
- Note --dry-run in --auto help text as preview escape hatch
* fix(tools): flag piped POSIX utilities (head/tail/grep/...) before running on Windows
Zero always executes the bash tool through native cmd.exe on Windows
(shellExecutable() in bash.go), but the pre-flight Windows-syntax
detector only caught bash-style `cd /...` and bare `ls`. Anything
piped into head/tail/grep/wc/awk/sed/cut/xargs/tr — very common in
model-generated commands like `git log ... | head` — ran straight
through to cmd.exe, which has no such builtins, wasting a turn on a
guaranteed failure. Reported in the wild via `git log ... | head`
and a `git blame ... | grep` pipeline.
detectShellCommandIssue now also flags these before the command
runs, with the same "translate to cmd.exe / use Zero's native tools"
guidance already used for the cd/ls cases.
Note: the existing detectShellOutputIssue path (matching cmd.exe's
error text post-execution) only recognizes English strings and will
miss this on non-English Windows locales — the pre-flight check
above is locale-independent since it inspects the model-generated
command text rather than cmd.exe's translated error output.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* fix(tools): require whitespace/end after POSIX utility name, not just \b
CodeRabbit review on PR #412: windowsPosixUtilityPattern used \b after
the utility alternation, but \b only requires a non-word character
next — so tail-log.sh, grep-cli, and sed.exe would all false-positive
(the hyphen/dot satisfies \b without being a real command boundary).
Match windowsLSCommandPattern's existing convention: require
whitespace or end-of-string after the utility name instead.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
When config.json has providers configured but none marked active
(e.g. a blank/stale activeProvider field, or a config with 2+
providers where none was ever selected), normalizeProviders threw
away the already-normalized provider list on ErrNoActiveProvider.
That meant the interactive TUI's existing "fall back to an
already-configured usable provider" logic (firstUsableProvider)
never got a chance to run for this case — the app instead wiped
resolved to a zero value and forced the user into a brand-new
onboarding wizard, even though a usable, already-saved provider
existed.
- normalizeProviders/Resolve now return the normalized providers
list alongside ErrNoActiveProvider instead of discarding it.
- runInteractiveTUIWithSetup tries firstUsableProvider on that list
before forcing setup; onboarding only runs when no configured
provider is actually usable, matching the existing fallback
already used for a different flavor of this problem
(active-provider-present-but-no-credential).
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanth T <[email protected]>
humanCount (the status-line/sidebar token formatter) only ever produced a K
suffix, so a 1,000,000-token context window rendered as "1000K" instead of
"1M" - visible with any 1M-context model (e.g. xai/grok-build-0.1). Its
sibling formatContextWindow already switches to "M" above a million; extend
the same behavior to humanCount, generalized for arbitrary (non-round)
values rather than formatContextWindow's round-number-only heuristic, since
humanCount also renders live, arbitrary "used" token counts.
Co-authored-by: Claude Fable 5 <[email protected]>
A malformed/truncated windows-unelevated-setup.json returned a hard error
from loadWindowsUnelevatedSetupMarker, bricking every unelevated command
until the file was hand-deleted, while the unknown-schema path already
reset to an empty marker. Treat corrupt JSON the same way: the marker only
memoizes idempotent ACL applies, so a reset can only cost redundant work,
never skip enforcement.
Addresses the review feedback from coderabbitai and @anandh8x on #427.
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(provider-wizard): allow multiple custom OpenAI-compatible providers
Generic/custom providers (custom-openai-compatible, custom-anthropic-compatible)
all share the same CatalogID. wizardProviderStoredKey matched by CatalogID,
which blocked creating more than one custom provider.
Fix: in advanceProviderWizard, skip the ManageKey diversion for custom
providers and route directly to the endpoint step instead. The user can
overwrite an existing instance by re-entering the same name, or create a
new one with a different name.
Named providers (OpenAI, Anthropic, etc.) are unaffected — ManageKey is
still shown for them.
Fixes#375
* refactor(provider-wizard): deduplicate advance/discovery branch, add anthropic-compatible test
---------
Co-authored-by: Baran3575 <[email protected]>
Before the one-time elevated `zero sandbox setup` has run, Windows dropped
straight from the native sandbox to the in-process policy gate: commands ran
with no OS enforcement at all. But only the WFP network filters actually need
Administrator rights - the write-restricted token and the workspace ACL grants
(DACL edits on user-owned roots) work unelevated.
Add an `unelevated` enforcement tier between native and degraded: when the
elevated setup marker is missing and the profile restricts the filesystem, the
command still wraps through the command runner at a new `unelevated` sandbox
level. The runner applies the workspace ACL plan itself (recording applied
plans by hash in windows-unelevated-setup.json so repeat commands skip the
apply) and launches under the same write-restricted token, so the OS-level
write-jail holds. Network enforcement stays with the per-command approval gate
at this tier, and the downgrade reason says so; profiles that need the sandbox
only for network isolation keep the old degrade behavior, and `--sandbox
require` still errors with the setup hint.
Verified end to end on Windows 11 without elevation: a write inside the
workspace succeeds under the restricted token, a write outside every granted
root is denied by the OS (new ZERO_SANDBOX_REAL_SMOKE integration test, using
cmd.exe because managed PowerShell cannot initialize its crypto provider under
a write-restricted token on some hosts).
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(providers): split minimax zai into intl cn
- Add minimaxi-cn (api.minimaxi.com) sharing model catalog with minimax
- Add zai-cn (open.bigmodel.cn) splitting zai from china to intl
- Decouple env vars: ZAI_API_KEY vs ZHIPU_API_KEY for zai/zai-cn,
MINIMAX_API_KEY vs MINIMAXI_API_KEY for minimax/minimaxi-cn
- Map zai-cn and minimaxi-cn to models.dev providers "zai" and "minimax"
so the model catalog hydrates for both endpoints
* chore(providers): address review feedback on intl/cn split
- Run gofmt on internal/providermodelcatalog/catalog.go (map-value
alignment on the \"zai\" entry was off by one space, fails CI lint)
- Update stale comment in TestResolveAppliesZaiInternationalCatalogDefaults
to say glm-4.5 default (live discovery upgrades to glm-5.2)
- Add missing ProviderKind, APIKeyEnv, and APIKey assertions to
TestResolveAppliesZaiInternationalCatalogDefaults so it would catch
a silent regression in ZAI_API_KEY routing
Add LongCat-2.0 as a first-class OpenAI-compatible provider preset so it can be
selected by name in the setup wizard / `zero providers setup longcat` instead of
hand-entering a custom endpoint.
LongCat-2.0 is Meituan's open, OpenAI- and Anthropic-compatible model. This
wires the OpenAI-compatible transport:
base URL: https://api.longcat.chat/openai (Zero appends /chat/completions)
model: LongCat-2.0
auth: Bearer via LONGCAT_API_KEY
Purely additive — one descriptor plus its catalog/onboarding tests and a README
env-var line; no shared plumbing is touched, so existing providers are
unaffected. Placed with the other OpenAI-compatible third parties (after
moonshot), below the first-party providers.
Verified: go build / vet / test green (catalog + cli onboarding), gofmt clean,
and `zero providers catalog` lists the descriptor with the correct fields.
Endpoint is wired by-spec from LongCat's published docs; a live completion still
needs a real LONGCAT_API_KEY to confirm.
* fix(tui): compose help overlay through the viewport overlay pipeline
exactly like how other popups works.
* fix for the CI/CD of formatting
* Added regression tests for the help overlay
* formatted code
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked
forever in executeToolCall -> requestPermission, blocked on the TUI's
decision channel. Root cause: a shell command whose sandbox decision was
Allow but which requested additional sandbox permissions
(sandbox_permissions: with_additional_permissions) made shouldRequestPermission
block on OnPermissionRequest, yet buildPermissionEvent stamped the event
Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved
Action=prompt requests and silently dropped everything else — so the decide
callback was never called, the agent waited on a channel nothing would signal
(no ctx cancellation on the active run), and the UI showed "Working" with no
prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's
folder-creation command set the additional-permissions flag, glm's did not,
which is exactly why one hung and the other ran clean.
Two-part fix:
- agent (buildPermissionEvent): an additional-permissions request is an
ELEVATION the user must consent to, even over a sandbox Allow, so mark it
Action=prompt (matching shouldRequestPermission's own condition). It now
surfaces as a real prompt instead of a dropped auto-allow.
- tui (permissionRequestMsg handler): enforce the contract that a blocked
agent ALWAYS gets a decision. A non-prompt action resolves immediately and
FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant
an unsurfaced permission; a superseded-run (runID mismatch) resolves as
cancel so its parked goroutine unblocks without depending on ctx-cancel
ordering. Matches the ACP handler's always-resolve, fail-closed contract.
Red-green: reverting the tui branch reproduces the deadlock (decide never
called); reverting the agent override reproduces Action=allow. End-to-end
against a mock backend emitting the exact triggering tool call, the real TUI
now renders the permission prompt and the run completes on approval, where
before it parked forever.
The model picker rendered each row's title from the model's description via
modelPickerDisplayName, which returned the description whenever it was non-empty
and not a known-generic placeholder. Catalog/discovery descriptions come from
models.dev as full sentences (e.g. "DeepSeek chat model for instruction
following, coding, and analysis"), and several models share the same sentence —
so the picker showed sentence-long titles and rows that looked like exact
duplicates, with the real model id surviving only in the meta line.
Prefer the (prettified) model id as the row title; fall back to a non-generic
description only when there is no id to name the row. Distinct ids that share a
catalog description now render as distinct rows.
* feat(providers): add KiloCode and OpenCode provider support
Add KiloCode (Kilo Gateway), OpenCode Zen, and OpenCode Go as catalog
entries using the existing openAICompat transport. No new runtime code
needed — both gateways expose standard OpenAI-compatible /chat/completions.
Fixes#383
* fix(providers): swap OpenCode Zen default model to deepseek-v4-flash
gpt-5.5 is served via /responses on Zen, not /chat/completions.
deepseek-v4-flash works with openAICompat transport.
Co-Authored-By: Vasanthdev2004
`zero providers` had no way to list the models a provider actually serves, so a
custom OpenAI-/Anthropic-compatible endpoint (e.g. a self-hosted proxy serving a
dozen models) forced users to hand-write a config object per model. Live model
discovery already existed for the TUI picker but had no headless CLI surface.
Add `zero providers models [name] [--json]`: it probes the provider's live
model-listing endpoint (an OpenAI-compatible /v1/models or the Anthropic /models)
via providermodeldiscovery.Discover, using the profile's base URL and resolved
credential (inline -> stored -> env). This uses the raw discovery primitive with
no curated-catalog merge or coding-model filtering, so a custom provider's full
model list is returned. Configure the provider once, run `zero providers models`
to see what it serves, and use any id with `zero exec --model <id>` (Zero passes
unknown model ids through to the provider) -- no per-model config needed.
Wired through a new appDeps.discoverProviderModels hook (default:
providermodeldiscovery.Discover) so the command is unit-tested hermetically.
Fixes#381.
* fix(config): default missing google/anthropic models from the catalog
A hand-written provider entry like {name: "google", provider_kind:
"google", apiKey: ...} with no model bricked Zero: config resolution
failed with "provider google requires model", and since zero config and
bare zero setup both resolve up front, the only commands that could have
fixed the config died with the same error (reported in the wild — the
one working escape, zero setup google --model <m>, is undiscoverable
from that message).
The openai kind has always defaulted a missing model
(modelregistry.DefaultModelID); google and anthropic were missed. Fall
back to their provider-catalog default models (gemini-2.5-pro /
claude-sonnet-4.5) the same way, behind the same gate: provider-command
configs (normalizeProvidersWithoutModelDefaults) still surface exactly
what the external command returned. An explicitly configured model
always wins, and profiles with a catalogID already got this via
applyCatalogDescriptor — this covers the catalog-less hand-written case.
The residual requires-model error (openai-compatible customs, which
have no catalog default) now carries an actionable hint: add "model" in
config.json or re-run zero setup with --model.
Tests: google/anthropic defaulting (red-green: reverting the google
fallback reproduces the exact reported error), explicit model wins,
provider-command gate still errors with the hint, and validation treats
official-kind model-less profiles as clean while still flagging customs.
* fix(cli): enter setup on a requires-model config and hint zero auth login
Two residual first-run gaps after the catalog model-defaulting fix:
- An active provider whose model has NO catalog default to fall back on
(custom openai-/anthropic-compatible endpoints) still bricked bare
`zero` and `zero setup` — the exact commands that could have repaired
the config. Tag that resolve failure with ErrProviderRequiresModel and
extend the interactive TUI's fixable-error fallback (previously only
ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel
is attached via a message-preserving wrapper (multi-target Unwrap), so
headless commands (zero config / zero exec) print the exact actionable
text unchanged and every other resolve error stays fatal.
- `zero login` / `zero logout` don't exist (it's `zero auth login`);
first-run users try them and got only "unknown command" (reported in
the wild). The unknown-command error now answers: did you mean
"zero auth login"? Unrelated unknown commands get no suggestion.
Tests: the sentinel comes from a REAL Resolve over a real config file
(message-prefix asserted, no key leak); the TUI fallback is red-green
(reverting the condition reproduces the exact brick with the old error);
the existing no-active-provider fallback and other-errors-stay-fatal
tests still pass; login/logout hint with a no-false-positive check.
* fix(tools): CRLF line ending mismatch in edit_file tool on Windows
* fix: resolve gofmt error, add robust CRLF guard, and fix Windows sandbox and OAuth test flakiness
* security(config): project config should not override sandbox.network to allow
* fix(config): use NormalizeNetworkMode in project config merge, per coderabbit review
Every exec against the Google/Gemini provider failed: Zero emits tool
parameter schemas in OpenAI's JSON Schema dialect, which always includes
`additionalProperties` (schemaToRuntimeMap writes it unconditionally).
Google's functionDeclarations schema doesn't recognize that keyword and
400s the whole request — "Unknown name \"additionalProperties\" …
Cannot find field" — so no tool-using call ever reached the model.
Sanitize tool parameters in the Gemini mapper (only there — the field is
valid and wanted for OpenAI strict mode and harmless for Anthropic):
- sanitizeGeminiSchema keeps only the keywords Gemini's Schema type
supports (type, description, enum, properties, required, items, anyOf,
default, nullable, format, and the min/max/length constraints) via an
allowlist, so any future OpenAI-ism or MCP-supplied field can't leak.
- Recurses through properties, items, and anyOf so nested schemas are
cleaned at every depth, not just the top level.
Tests: a unit test on the sanitizer (nesting, legit fields preserved,
nil-safe) and an end-to-end test asserting the outgoing request body's
functionDeclarations[].parameters carry no additionalProperties while the
real schema survives. Bypassing the sanitizer reproduces the reported
payload, so the coverage is red-green.
Fixes#373
On macOS, DefaultUserConfigPath used Go's os.UserConfigDir which returns
~/Library/Application Support, while users (and Claude Code) place config
at ~/.config/zero/config.json. The user config was never read, leaving
activeProvider empty and the launch failing with `active provider "" not
found` on a fresh `go run cmd/zero setup`.
- Add config.UserConfigDir(): on darwin, resolve to ~/.config (honoring
XDG_CONFIG_HOME), matching Linux and Claude Code. DefaultUserConfigPath,
the TUI user-commands dir, and specialist manifests all route through it.
- Add config.ErrNoActiveProvider sentinel; the interactive TUI now drops
into the setup wizard when resolve fails solely due to a missing/
unresolvable active provider, instead of exiting fatally. Other resolve
errors (malformed JSON, directory conflicts) remain fatal.
Note: `zero setup`/`zero auth` already MkdirAll the config dir before
writing, so the directory is auto-created on first write.
Co-authored-by: smallnest <smallnest@gmail>
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider
Sub-agents, swarm members, ACP sessions, and mid-run escalation switches
could build providers without the credentials the parent was running with.
This consolidates credential resolution so every surface gets the same auth:
- Wrap deps.newProvider once in fillAppDeps to fold the stored API key in
centrally; ACP, exec escalation, and buildProvider no longer patch it
per call site (an apiKeyStored profile previously went out keyless on
those paths and 401'd).
- Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on
every runtime switch (wizard, onboarding, /model, /provider), so spawned
children resolve the parent's live provider instead of whatever another
process last persisted to config.json. Injected via appDeps so tests
never mutate the process environment.
- Share one OAuth login selection end to end: ProviderProfile.
OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a
profile with its own configured credential) + oauth.FirstStored resolve
the login ONCE, and the bound store key is threaded through
providers.Options.OAuthLoginKey so the Codex account-id header always
reads the same login that issued the bearer token. A renamed profile
(name != catalog ID) now finds its login; an explicit API key is never
silently overridden by a same-name or catalog-shared OAuth login.
- Export ProviderProfile.HasConfiguredCredential as the single "profile is
key-authed" predicate (cli/tui/config previously had three copies).
- applyProviderWizard commits live state (provider, profile, name, env
export) only after BOTH the build and the config persist succeed, so a
persist failure no longer leaves the chat running on a provider the
status line and children disagree with.
Covered by red-green tests for each path: stored-key wrap, escalation
switch, catalog-ID fallback (incl. case-variant names), explicit-key
protection, same-login account selection, wizard persist-failure rollback,
onboarding/startup env export.
* test: isolate OAuth/config test storage and fix the flaky MCP response-id test
Review + CI fixes, all test-only:
- factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp
ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to
the OS keychain and ignores the temp path entirely, so the tests could
read (or clobber) the developer's real logins. newOAuthStoreForTest now
owns both pins so a future call site can't forget them.
- TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath
at a temp config and stub newProvider, so the SUCCESS path exercises the
full build+persist+commit sequence in isolation (the default empty path
silently skipped persist; a changed default must never reach the real
user config).
- TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the
responses were pre-loaded into a bytes.Buffer, so the client's reader
goroutine could consume the matching response — and hit EOF — before
request() registered its pending id, failing with "request() error =
EOF" on fast runners. The fake server now sits on pipes and responds
only after reading the outgoing request, matching production ordering;
survives -race -count=50.
* feat(tui): FILES sidebar panel with click-to-select and file drill-in
Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.
Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.
The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.
* fix(tui): address FILES sidebar review findings
Review fixes for the FILES panel / drill-in / git sweep:
- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
apply_patch spanning several files no longer charges the whole patch's
+/- totals to every file it touched; order by lastRowIndex descending
so a re-touched file lists first (reversing first-seen order kept it in
its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
"+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
rename records (counts + preimage + postimage) — plain --numstat
brace-mangles renamed paths ("src/{old => new}"), so their diffstat
never matched a porcelain path and renamed files stayed at 0/0;
gitTouchedFiles now yields newest detection first to match the
roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
(os.ReadFile loaded a multi-GB file wholesale before truncating — the
exact freeze the cap exists to prevent); re-opening the file already
being viewed is a no-op so a stray re-click can't bounce full mode
back to diff or reset scroll; skip sub-4-char lines in the changed-line
markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
rename postimage key; new coverage for per-file stats, re-touch
ordering, live-row overflow, same-file reopen, truncation, modal
gating, live-write content, and a real-repo rename sweep.
---------
Co-authored-by: Gnanam <[email protected]>
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall
Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck"
reports: the ChatGPT Codex backend pauses real generation mid-stream on
large single tool-call outputs (a ~1000-line write_file) while continuing
SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug
or a connection-pooling issue (the darwin DisableKeepAlives fix is applied
and confirmed not the cause). Zero already detected it, but two UX gaps
made it feel like a hang:
1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes.
A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a
frozen token counter was a terrible wait for a doomed turn. Replaced the
StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2
(6 minutes at the default). It still only ever fires on a TRUE zero-output
freeze — any real data line resets it — so healthy slow-reasoning runs are
unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled)
when idle is disabled.
2. Auto-retry the actual stall. The existing stall-retry only fired when
NOTHING had been forwarded — so the common case (model streamed transient
reasoning + began the big write_file, then froze mid-arguments) was NOT
retried, because the incomplete tool call is collected and transient
output was forwarded. Relaxed the gate to retry when no visible PROSE was
shown (forwardedVisibleText, marked only by OnText) and no final text was
collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete
tool call from a timed-out stream is never executed (the turn returns on
the error before dispatch) and never appended to messages, so the retry
re-sends clean context with no duplication. A visible
"[no output — model stalled; retrying N/max]" notice fires per attempt.
Forwarded prose still blocks the retry (it would duplicate answer text).
Verified: full -race suite green; a 4-dimension root-cause audit and a
3-dimension adversarial review of this diff both passed with no confirmed
defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact
gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved
TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry).
* fix(providerio): make ContentStallTimeout overflow-safe
Address PR #362 review: idleTimeout*6/5 could overflow int64 for a
pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE
duration, which would arm the content-stall timer to fire immediately and
abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max
duration on overflow (which just means "effectively no content watchdog",
the sane result for an absurd idle timeout). The normal 1.2× values are
unchanged (5m->6m, 30s->36s, 100ms->120ms).
New test: TestContentStallTimeout covers the scaled values, the
zero/disabled case, and the MaxInt64 overflow guard.
A message typed while a run is active is queued for the next turn. Its
"[queued] …" preview rendered on the line BELOW the input box; move it to
directly ABOVE the box so it reads as "waiting to send," sitting on top of
what you're currently typing, and doesn't get visually detached from the
composer by the status line beneath it. Footer height is unchanged (the
preview line just moves from below to above the box).
New test: TestQueuedPromptPreviewSitsAboveComposer asserts the preview
line index is above the composer box top border.
This codebase already has a documented, previously-diagnosed macOS-only
bug: "sharedHTTPClient... defeats the stale-pooled-connection hang...
reproduced on BOTH chatgpt and ollama... surfaces on macOS far more than
Linux because macOS keeps dead pooled connections around longer." The
existing mitigation (ResponseHeaderTimeout=120s, IdleConnTimeout=30s)
catches a reused connection that never responds at all, or one that sat
idle too long.
Traced a live report that doesn't fit either case: a session accumulated
301 real completion tokens over ~12.5 minutes (~0.4 tok/s) on
chatgpt/gpt-5.5 before being manually cancelled - not silent, just
catastrophically slow, on a retry shortly after a prior attempt on the
same host. That's a third failure mode: a reused connection that's
alive-but-severely-degraded, still delivering real bytes (enough to keep
resetting Zero's stream idle/content-stall watchdogs, which only fire on
true silence) but never at a usable rate. Basic network diagnostics on
the affected machine (DNS, TCP connect, TLS handshake) were all healthy
and routed directly with no VPN/proxy involved, pointing at the same
connection-reuse class of bug rather than a local network misconfig.
DisableKeepAlives removes pooling from the equation entirely rather than
just tightening the timeout window, since a degraded-not-dead connection
isn't reliably distinguishable from genuine backend slowness once inside
the stream. Scoped to darwin only via runtime.GOOS: a fresh TCP+TLS
handshake per request costs low tens of milliseconds (measured ~92ms
against a real host on the affected machine) against potential
minutes-long stalls, and Linux/Windows don't exhibit this OS-level
pooled-connection behavior, so they keep full connection reuse.
New test: TestHTTPClientReturnsStallHardenedSharedClient extended to
assert DisableKeepAlives matches runtime.GOOS == "darwin".
Both failed on windows-latest today, on merges of otherwise-unrelated
PRs (#349, #356) - neither PR touched internal/specialist or
internal/agenteval. Windows GitHub Actions runners have coarser default
timer granularity and more scheduling contention than Linux/macOS, so
tight timing margins that hold on the other two platforms aren't
reliable there.
- TestOutputToolBlocksUntilTaskCompletes: a 100ms poll timeout against a
5ms background write left only 20x margin. Widened the timeout to
2000ms (a ceiling, not a target - PollInterval keeps the test fast in
the success case; it only changes how much CI jitter it tolerates
before failing).
- TestHarnessTimeoutCancelsBlockedAgent: confirmed there's no actual data
race (harness.Agent.Run is a direct synchronous call, not a spawned
goroutine racing the assertions) - the failure was workspace
materialization alone consuming the full 10s budget on a contended
Windows runner, so the agent was never reached. Widened to 30s. This
test's own design blocks for the full timeout to observe cancellation,
so unlike the other fix this directly costs ~20s more wall-clock time,
but that's inherent to what it's testing.
Zero is always in alt-screen mode (useAltScreen() is unconditionally
true), and transcriptView's `if m.altScreen && m.height > 0` gate is what
pads the rendered frame out to fill the full terminal height
(scrollableTranscriptItemsView pads bodyWindow to window.height). If
m.height is ever 0 - Bubble Tea documents an initial WindowSizeMsg as
delivered automatically on program start, but that's the terminal
proactively pushing a size, and nothing else ever asks again if that
push is missed (a slow/unusual terminal, a multiplexer, a startup race)
- Zero falls back to the unpadded, non-fullscreen render path for the
rest of the session: content only occupies as many rows as it needs,
leaving the remaining terminal height as plain unfilled background
instead of the alt-screen's painted panel color.
Fixed by explicitly firing tea.RequestWindowSize in Init(), the same
defensive pattern already used for tea.RequestBackgroundColor right below
it - Zero doesn't rely solely on an unprompted push for either.
Zero already has a content-stall watchdog built specifically for this
exact case (providerio.ErrStreamStalled's own doc comment: "observed on
chatgpt/gpt-5.x and ollama reasoning models") - a heartbeating-but-silent
upstream is aborted after idleTimeout * StreamContentStallFactor (5min *
2 = 10min by default). It works. The gap is that until it fires, the
"still generating... Xs" cue is the ONLY signal the user has, and it
looks completely identical whether real (if slow) content is still
coming or the stream is already dead and just hasn't been caught yet -
confirmed against a live report where the token count sat frozen for
7+ minutes with no way to tell from the UI alone.
Past half the effective idle timeout, quietGenerationHint() now escalates
to name what's actually happening and when Zero will act on its own:
"still generating... Xs - unusually quiet, Zero will auto-recover by
~Ys if it doesn't resume". Exported StreamContentStallFactor from
providerio (was unexported) so the TUI can compute the same ceiling the
watchdog itself uses, instead of duplicating the constant.
New test: TestQuietGenerationHintEscalatesPastHalfIdleTimeout, using
ZERO_STREAM_IDLE_TIMEOUT to shrink the timeout so the test runs fast
while exercising the exact same math the real watchdog uses.
* fix(tui): show the context-fill gauge for custom/local Ollama models
The colored context gauge (used/window · N%) only renders when Zero knows
a model's context window. It resolves that two ways: a curated remote
catalog (how Grok/hosted models are known), or live /v1/models discovery —
but parseModelsResponse only ever extracts id/description from that
response; OpenAI-compatible model listings don't carry context-window
metadata at all. A custom/local Ollama model tag (e.g. a locally-pulled
":cloud"-tagged model) has no curated-catalog entry to borrow one from
either, so it always resolved to an unknown window and the gauge silently
fell back to a bare token count with no color grading.
Ollama does expose this, just via a different, Ollama-native endpoint
(/api/show, not /v1/models) whose model_info map reports it under an
architecture-prefixed key like "qwen2.context_length". Added
DiscoverOllamaContextWindow (providermodeldiscovery) to probe it, wired
through an async command scoped to the local "ollama" catalog ID only
(not "ollama-cloud", a different hosted service this endpoint isn't
assumed to exist on) — fired from Init() for the provider active at
launch, and from switchProviderModel for a provider switched to
mid-session (which previously warmed no discovery at all for the new
provider, a separate gap this also fixes).
New tests: DiscoverOllamaContextWindow's endpoint construction and
model_info parsing (including a mixed-type model_info and a missing
*.context_length key), the discovery command's scoping to local Ollama
only, the Update() handler populating modelContextWindow's fallback, and
switchProviderModel warming discovery for the newly active provider.
Local only — not pushed. I can't run a live Ollama daemon in this
sandbox, so the actual /api/show request/response shape (verified against
Ollama's documented API, not a live instance) needs confirming against a
real daemon before this goes out.
* fix(tui): stop showing the token count twice next to the context gauge
contextWindowSegment (the gauge) and usageStatusSegment (the plain "N tok"
figure) both read the exact same value (latestUsageTokens) — the code
already suppressed the plain figure when the sidebar shows it ("the token
figure shows on both sides"), but never extended that same dedup to the
gauge. So as soon as a model's context window becomes known (e.g. after
the previous commit's Ollama fix), the footer showed the identical number
twice: once inside the gauge, once again right next to it.
Fixed by falling back to cost-only whenever the gauge is shown, same as
the sidebar-active branch already does.
* fix: check collect()'s deadline even while output keeps draining
The wait deadline in execSession.collect() was only checked in the branch
reached when drainString() returns empty. A background process producing
output continuously enough to keep the buffer perpetually non-empty could
theoretically starve that check forever, turning the caller's requested
timeout into an unbounded block regardless of yield_time_ms.
Investigated as part of a cross-provider "stuck" report (multiple sessions
across different models/providers all hanging on the same prompt, each
with a background terminal running and no forward progress). Could not
empirically reproduce a hang via a synthetic continuous-writer test under
Go's real scheduler — the reader won the race often enough in practice
that this doesn't appear to be the actual cause of those reports. Keeping
the fix regardless since it closes a real, if hard-to-trigger, gap: the
deadline is now checked in both branches, so continuous output can never
prevent collect() from returning within its requested window.
The provider wizard offered "Sign in with OAuth" for xAI and Hugging Face, but
clicking did nothing: the login managers were built without an env, so the preset
opt-in (ZERO_OAUTH_ALLOW_PRESETS) read as unset, ResolveConfig errored before any
browser step, and the error was never rendered on the provider list. Only ChatGPT
worked, because its login path force-enables presets.
Add ManagerOptions.AllowPresets, which layers the opt-in onto the manager env
(snapshotting the process environment so ZERO_OAUTH_<NAME>_* overrides survive
envValue's hermetic-map rule) without the operator exporting the flag. Set it on
every user-initiated OAuth manager: the wizard loopback login, the device-code
prepare/complete, the post-login token refresh, the runtime resolver that
authenticates model calls, and `zero auth login`. This fixes xAI end to end (it
ships a public client_id, so the browser now opens).
Also surface a failed OAuth attempt on the provider list (renderProviderStep) so
the click is no longer a silent no-op, with a Hugging Face hint pointing at app
registration + ZERO_OAUTH_HUGGINGFACE_CLIENT_ID (HF ships no public client_id, so
it still requires that one env var by design).
* 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.
* 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.
* fix(tui): require a second Esc press to cancel a running turn
A single stray Esc during an active run cancelled it instantly with no
chance to reconsider - e.g. a press meant to dismiss an overlay that had
already closed. Ctrl+C already guards app-exit with a double-press
confirmation (exitConfirmActive/exitConfirmSeq); Esc now gets its own
independent confirmation (cancelConfirmActive/cancelConfirmSeq) guarding
run-cancellation specifically, since the two are different actions with
different consequences armed by different keys.
Mirrors the existing seq-gated tea.Tick pattern: first Esc arms a 3s
window and shows "Press Esc again to cancel" in the footer; a second Esc
within the window cancels the run; letting it expire, or the run finishing
on its own, disarms without cancelling.
Updated the three existing tests that assumed single-press cancellation
(TestEscCancelsPendingRun -> TestEscRequiresSecondPressToCancelPendingRun,
TestEscCancellationLeavesVisibleMarker, TestEscCancelRecordsSessionError,
TestCancelledRunFlushesCheckpointSessionEvents) and added
TestEscCancelConfirmationExpires mirroring the Ctrl+C expiry test.
* fix: pre-push review findings on the Esc cancel-confirmation gate
Ran an adversarial review before opening the PR. Three independent
reviewers converged on the same root defect: unlike exitConfirmActive
(disarmed by any key other than Ctrl+C), cancelConfirmActive had no
"anything else disarms it" safety net, so it behaved as a plain 3s timer
rather than a true immediate-second-press gate. Concretely:
- Typing, Ctrl+O, or any other non-Esc key left it armed, so a later,
unrelated Esc press could silently cancel the run.
- A mid-turn ask_user or permission prompt lands asynchronously and is
checked earlier in the Esc handler than the cancel-confirm block, so it
silently "stole" the confirming press (denying the prompt instead) while
leaving cancelConfirmActive armed for a subsequent, unrelated Esc to act
on.
- Mouse clicks resolving a permission prompt or entering a subchat had the
same gap.
Fixed by mirroring the Ctrl+C pattern: any non-Esc key disarms it (model.go,
next to the existing exitConfirmAction reset), and any left/right mouse
click disarms it too (mouse.go — scoped to clicks, not hover/motion, since
motion fires continuously and would otherwise defeat the window whenever
the mouse sits over the terminal). Within the Esc handler itself, restructured
to capture whether this press was really a confirm *before* any of the
earlier branches (subchat, MCP wizard, ask-user, permission, picker,
suggestions, queued-message, detailed-transcript) can run, then disarm
unconditionally — so an Esc consumed by any of those no longer leaves a
stale confirmation for a later press to trip.
Also updated two now-stale help strings the review flagged: the `?`
overlay's Esc row and pickerBusyText()'s "press Esc to cancel" hint, both
of which still described the old single-press behavior.
Added TestEscCancelConfirmationDisarmsOnInterveningKey (mirrors the
existing Ctrl+C equivalent) and
TestEscCancelConfirmationDisarmsWhenStolenByAskUser covering the two
confirmed gaps.
* fix: address CodeRabbit review comments on PR #352
- model.go: the first Esc (arming the cancel confirmation) was clearing the
composer draft immediately, even though nothing had actually been
cancelled yet — a leftover from the old single-press behavior, where
clearing the draft and cancelling the run happened atomically as one
action. Now that cancellation is two presses, only the confirming second
Esc (or a plain Esc with no run pending) clears the composer; arming
preserves whatever the user was typing, mirroring how Ctrl+C already
preserves a draft (TestCtrlCClearsComposerBeforeExitConfirmation).
- clipboard.go: routePaste (bracketed paste and right-click paste) never
disarmed a stale cancelConfirmActive — it isn't a tea.KeyPressMsg, so it
bypassed the generic "any non-Esc key disarms it" hook added in the
previous commit. A paste is a deliberate action like typing or clicking,
so it should disarm the confirmation too; otherwise a later, unrelated
Esc could silently cancel the run.
New tests: TestEscArmingCancelConfirmationPreservesComposerDraft,
TestPasteDisarmsCancelConfirmation.
* 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.
* 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.
Restores accent to #caff3f (the pre-PR#350 value), undoing the three-round
brightness reduction (#ade619 -> #8db620 -> #759523) merged in PR #350. Only
the accent token changes - the hover-highlight color (a separate amber
token) and every other PR #350 fix (subchat selection, drag-to-edge
auto-scroll, the dragging-flag fix) are untouched.
Full gate green. Local only - not pushed.