Compare commits

..
Author SHA1 Message Date
anandh8x 16405c489b Clean up public release docs
Rewrite the README around the initial public-release user flow, add the logo asset, and refresh install/update documentation.

Remove internal planning, audit, design, and work-split documents from the public docs tree.

Update stale documentation references and workspace seed expectations to point at public docs.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/workspaceseed

Tested: git diff --check
2026-06-30 08:08:26 +05:30
28 changed files with 128 additions and 416 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,6 @@ require (
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
@@ -22,6 +21,7 @@ require (
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
+2 -12
View File
@@ -277,9 +277,6 @@ func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatal
return nil, err
}
sort.SliceStable(summaries, func(i int, j int) bool {
if summaries[i].Recommended != summaries[j].Recommended {
return summaries[i].Recommended
}
return summaries[i].ID < summaries[j].ID
})
return summaries, nil
@@ -357,17 +354,10 @@ func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
}
func formatProviderCatalogLine(provider providerCatalogSummary) string {
name := formatProviderCatalogValue(provider.Name, "unknown")
prefix := " id="
if provider.Recommended {
prefix = " ★ id="
name += " (recommended)"
}
lines := []string{
fmt.Sprintf("%s%s name=%s",
prefix,
fmt.Sprintf(" id=%s name=%s",
formatProviderCatalogValue(provider.ID, "unknown"),
name,
formatProviderCatalogValue(provider.Name, "unknown"),
),
fmt.Sprintf(" transport=%s defaultModel=%s",
formatProviderCatalogValue(provider.Transport, "unknown"),
-1
View File
@@ -285,7 +285,6 @@ func setupProviderOptions() []tui.SetupProviderOption {
EnvVar: setupProviderEnvVar(descriptor),
RequiresAuth: descriptor.RequiresAuth,
Local: descriptor.Local,
Recommended: descriptor.Recommended,
})
}
return options
-20
View File
@@ -140,26 +140,6 @@ func TestPostinstallSkipsUnsupportedPlatform(t *testing.T) {
}
}
func TestPostinstallSkipsWindowsArm64(t *testing.T) {
// (win32, arm64) resolves to a valid platform/arch but the release matrix has
// no windows-arm64 artifact, so the install must skip gracefully (exit 0)
// rather than hard-fail on a 404 download.
stdout, stderr, err := runPostinstall(t,
"ZERO_INSTALL_DRY_RUN=1",
"ZERO_INSTALL_PLATFORM=win32",
"ZERO_INSTALL_ARCH=arm64",
)
if err != nil {
t.Fatalf("windows-arm64 should exit 0, got err=%v stderr=%s", err, stderr)
}
if strings.TrimSpace(stdout) != "" {
t.Fatalf("windows-arm64 should not print a plan, got %q", stdout)
}
if !strings.Contains(stderr, "no prebuilt binary for windows-arm64") {
t.Fatalf("stderr=%q, want the windows-arm64 skip message", stderr)
}
}
func TestPostinstallHonorsSkipEnv(t *testing.T) {
stdout, stderr, err := runPostinstall(t, "ZERO_SKIP_DOWNLOAD=1")
if err != nil {
+1 -17
View File
@@ -58,11 +58,6 @@ type Descriptor struct {
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
// Recommended marks the provider that should be surfaced first and badged
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
// descriptor should set this.
Recommended bool
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -87,11 +82,6 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
}
var descriptors = []Descriptor{
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
// that smart-routes by model id across upstream providers (xiaomi-mimo,
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
// Bearer ogw_live_… key; listed first and badged in every picker.
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
@@ -134,6 +124,7 @@ var descriptors = []Descriptor{
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
@@ -275,13 +266,6 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
// recommended marks a descriptor as the recommended default so list/picker
// surfaces sort it first and render the ★ … (recommended) badge.
func recommended(descriptor Descriptor) Descriptor {
descriptor.Recommended = true
return descriptor
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
+2 -37
View File
@@ -8,7 +8,6 @@ import (
)
var expectedCatalogIDs = []string{
"gitlawb-opengateway",
"openai",
"anthropic",
"google",
@@ -34,6 +33,7 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"gitlawb-opengateway",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -61,41 +61,6 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
}
}
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
descriptors := All()
if len(descriptors) == 0 {
t.Fatal("All() returned no descriptors")
}
if !descriptors[0].Recommended {
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
}
if descriptors[0].ID != "gitlawb-opengateway" {
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
}
recommended := 0
for _, descriptor := range descriptors {
if descriptor.Recommended {
recommended++
}
}
if recommended != 1 {
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
}
}
func TestRecommendedProviderEndpoint(t *testing.T) {
descriptor, ok := Get("gitlawb-opengateway")
if !ok {
t.Fatal("gitlawb-opengateway not found in catalog")
}
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -264,7 +229,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
-19
View File
@@ -326,9 +326,6 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
return nil, false, err
}
endpoint := baseURL + healthPath(kind)
if override, ok := overrideHealthEndpoint(profile, baseURL); ok {
endpoint = override
}
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
@@ -595,22 +592,6 @@ func blockedAddrReason(addr netip.Addr) string {
return ""
}
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
// instead, which reports real reachability without a per-model call.
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
if profile.CatalogID != "gitlawb-opengateway" {
return "", false
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host + "/health", true
}
func healthPath(kind config.ProviderKind) string {
switch kind {
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
@@ -170,20 +170,3 @@ func TestProbeConnectivityRedactsProviderErrorDetails(t *testing.T) {
t.Fatalf("custom header secret leaked in connectivity check: %s", rendered)
}
}
func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
got, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://opengateway.gitlawb.com/v1")
if !ok {
t.Fatal("expected OpenGateway to override the health endpoint")
}
if got != "https://opengateway.gitlawb.com/health" {
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
t.Fatal("non-OpenGateway providers must not override the health endpoint")
}
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "::not a url"); ok {
t.Fatal("an unparseable base URL must not produce an override")
}
}
+3 -11
View File
@@ -129,18 +129,10 @@ var curatedModels = map[string][]Model{
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
},
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
// the curated coding defaults; the gateway accepts any model its upstreams
// expose, so users can also type an id the picker doesn't list.
"gitlawb-opengateway": {
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
{ID: "MiniMax-M3", Description: "MiniMax model"},
{ID: "qwen-plus", Description: "Qwen model"},
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
{ID: "glm-4.6", Description: "Z.ai model"},
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
{ID: "gpt-4.1", Description: "catalog default"},
{ID: "claude-sonnet-4.5", Description: "coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model"},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
+1 -1
View File
@@ -54,7 +54,7 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
t.Fatalf("derived = %q", got)
}
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://gateway.gitlawb.com/zero/models.json" {
t.Fatalf("fallback = %q", got)
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ func defaultedOpenGatewayURL(provider providercatalog.Descriptor, override strin
}
parsed, err := url.Parse(provider.DefaultBaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "https://opengateway.gitlawb.com/zero/models.json"
return "https://gateway.gitlawb.com/zero/models.json"
}
return parsed.Scheme + "://" + parsed.Host + "/zero/models.json"
}
+1 -19
View File
@@ -59,7 +59,6 @@ const (
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventContentPartAdded = "response.content_part.added"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
responsesEventOutputTextDone = "response.output_text.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventContentPartDone = "response.content_part.done"
@@ -87,10 +86,6 @@ type responsesRequest struct {
// here; omitted entirely when the caller requests no (or an unsupported) effort.
type responsesReasoning struct {
Effort string `json:"effort,omitempty"`
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
// level). Without it the backend emits no reasoning events, so a long thinking
// phase produces zero visible output and reads as a hang in the UI.
Summary string `json:"summary,omitempty"`
}
// inputItem is one element of the Responses `input` array. The Type field
@@ -261,9 +256,7 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
// and an empty or unsupported effort simply omits the field — without this the
// caller's chosen effort was silently dropped for every Codex model.
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
// long thinking phase shows live progress instead of looking hung.
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
req.Reasoning = &responsesReasoning{Effort: effort}
}
return req, nil
}
@@ -508,17 +501,6 @@ func (p *CodexProvider) emitResponsesEvent(
})
}
return true
case responsesEventReasoningDelta:
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
// phase shows progress (and keeps the activity clock fresh) instead of
// looking like a hang. Requested via reasoning.summary="auto".
if event.Delta != "" {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: event.Delta,
})
}
return true
case responsesEventFunctionArgsDelta:
p.handleFunctionArgsDelta(ctx, &event, state, events)
return true
-52
View File
@@ -485,58 +485,6 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
if reasoning["effort"] != "high" {
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
}
// A reasoning summary must be requested so the backend streams
// reasoning_summary_text deltas — otherwise a long think shows no live output.
if reasoning["summary"] != "auto" {
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
}
}
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
// "thinking"), in order, alongside the normal text output. Without this a long
// reasoning phase produces zero visible output and reads as a hang.
var rec codexRequest
srv := newCodexResponsesServer(t, &rec,
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
`{"type":"response.output_text.delta","delta":"done"}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
ReasoningEffort: "high",
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
var reasoning, text []string
for ev := range stream {
switch ev.Type {
case zeroruntime.StreamEventReasoning:
reasoning = append(reasoning, ev.Content)
case zeroruntime.StreamEventText:
text = append(text, ev.Content)
case zeroruntime.StreamEventError:
t.Fatalf("unexpected error event: %q", ev.Error)
}
}
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
}
if got := strings.Join(text, ""); got != "done" {
t.Fatalf("text deltas = %q, want %q", got, "done")
}
}
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
+5 -14
View File
@@ -385,14 +385,6 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
messages := make([]chatMessage, 0, len(request.Messages))
for _, message := range request.Messages {
// Drop a degenerate assistant turn that carries neither text nor tool calls
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
// already skip empty turns; without this, the contentless message reaches
// strict OpenAI-compatible servers and is rejected.
if message.Role == zeroruntime.MessageRoleAssistant &&
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
continue
}
messages = append(messages, mapMessage(message))
}
@@ -453,12 +445,11 @@ func mapMessage(message zeroruntime.Message) chatMessage {
// message that happens to carry Images keeps the plain string/nil content
// path (its images are simply not serialized).
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
// Always set content (to "" when empty) so it serializes as `"content":""`
// rather than being dropped. Strict OpenAI-compatible servers reject a
// message with no content field; tool results and assistant-with-tool-calls
// turns must still carry an (empty) content. Truly empty assistant turns are
// dropped upstream in openAIRequest.
mapped.Content = message.Content
// Preserve the legacy behavior: only non-empty text is serialized, so
// empty content is omitted by the `omitempty` tag.
if message.Content != "" {
mapped.Content = message.Content
}
} else {
parts := make([]contentPart, 0, len(message.Images)+1)
if message.Content != "" {
+85 -65
View File
@@ -1000,6 +1000,57 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
}
}
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{"path":"README.md"}`,
}},
},
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
},
}))
if err != nil {
t.Fatalf("marshal request: %v", err)
}
// String content round-trips as a JSON string, and empty content is omitted.
if !strings.Contains(string(got), `"content":"hello"`) {
t.Fatalf("user content not serialized as string: %s", got)
}
if strings.Contains(string(got), `"content":""`) {
t.Fatalf("empty assistant content must be omitted, got: %s", got)
}
// Decode the assistant message and assert no content key at all.
var decoded chatCompletionRequest
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
_ = decoded
if _, present := raw.Messages[2]["content"]; present {
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
}
if raw.Messages[1]["content"] != "hello" {
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
}
}
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
parts := []contentPart{
{Type: "text", Text: "look"},
@@ -1025,6 +1076,38 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
}
}
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{
Role: zeroruntime.MessageRoleAssistant,
Content: "",
ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1",
Name: "read_file",
Arguments: `{}`,
}},
},
},
}))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var raw struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal(got, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, present := raw.Messages[0]["content"]; present {
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
}
}
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
msg := mapMessage(zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -1082,8 +1165,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
}
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
if got, ok := empty.Content.(string); !ok || got != "" {
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
if empty.Content != nil {
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
}
}
@@ -1163,66 +1246,3 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
}
}
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
// `content` is absent/null with "invalid message content type: <nil>": a
// contentless assistant turn with no tool calls is dropped, and every other
// message serializes an explicit `"content"` field (empty string when there is
// no text) instead of omitting it.
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
req := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
// output): must be dropped, not sent.
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
// Assistant with tool calls but no text: kept, content present as "".
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
ID: "call_1", Name: "read_file", Arguments: "{}",
}}},
// Tool result with empty content: kept, content present as "".
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
},
})
if len(req.Messages) != 3 {
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
}
// No message may serialize with an absent/null content field.
for i, message := range req.Messages {
if message.Content == nil {
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
}
data, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal message %d: %v", i, err)
}
if !strings.Contains(string(data), `"content":`) {
t.Fatalf("message %d omits the content field: %s", i, data)
}
}
if req.Messages[0].Content != "hi" {
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
}
// The kept-but-textless messages must send content as an explicit empty string.
for _, idx := range []int{1, 2} {
data, err := json.Marshal(req.Messages[idx])
if err != nil {
t.Fatalf("marshal message %d: %v", idx, err)
}
if !strings.Contains(string(data), `"content":""`) {
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
}
}
if len(req.Messages[1].ToolCalls) != 1 {
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
}
}
+2 -7
View File
@@ -18,13 +18,8 @@ type streamOptions struct {
}
type chatMessage struct {
Role string `json:"role"`
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
// or null with "invalid message content type: <nil>". mapMessage always sets
// this (to "" when there's no text), so a contentless message serializes as
// `"content":""` rather than being dropped.
Content any `json:"content"`
Role string `json:"role"`
Content any `json:"content,omitempty"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
+1 -8
View File
@@ -1495,14 +1495,7 @@ func (m model) setupProviderLines(width int, height int) []string {
marker = "❯ "
style = zeroTheme.accent.Bold(true)
}
label := displayValue(option.Name, option.ID)
if option.Recommended {
label = "★ " + label
}
line := marker + style.Render(label)
if option.Recommended {
line += zeroTheme.faint.Render(" (recommended)")
}
line := marker + style.Render(displayValue(option.Name, option.ID))
lines = append(lines, padSetupLine(line, rowWidth))
}
// A failed OAuth login drops back here with oauthPending cleared, so the
-1
View File
@@ -99,7 +99,6 @@ type SetupProviderOption struct {
EnvVar string
RequiresAuth bool
Local bool
Recommended bool
}
// SetupSelection is the user's setup choice.
+2 -15
View File
@@ -1240,26 +1240,13 @@ func (wizard *providerWizardState) renderSelectableProvider(width int, index int
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
name := provider.Name
if provider.Recommended {
name = "★ " + name
}
left := marker + surface(zeroTheme.ink).Render(name)
if badge := providerWizardBadge(provider); badge != "" {
left := marker + surface(zeroTheme.ink).Render(provider.Name)
if badge := providerWizardOAuthBadge(provider); badge != "" {
left += surface(zeroTheme.faint).Render(" " + badge)
}
return fitStyledLine(left, width)
}
// providerWizardBadge is the faint hint shown next to a provider row. The
// recommended provider takes precedence over the OAuth hint.
func providerWizardBadge(provider providercatalog.Descriptor) string {
if provider.Recommended {
return "(recommended)"
}
return providerWizardOAuthBadge(provider)
}
// providerWizardOAuthBadge is the faint mode hint shown next to OAuth providers.
func providerWizardOAuthBadge(provider providercatalog.Descriptor) string {
if !provider.OAuth {
+2 -13
View File
@@ -53,7 +53,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
listView := plainRender(t, next.View())
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
for _, want := range []string{"Choose provider", "OpenAI", "Anthropic", "Google", "Groq", "OpenRouter", "Ollama"} {
assertContains(t, listView, want)
}
}
@@ -172,12 +172,8 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
// Index 0 is the recommended OpenGateway entry, so two downs land on anthropic
// (opengateway → openai → anthropic).
updated, _ := m.Update(testKey(tea.KeyDown))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyDown))
next = updated.(model)
if got := next.providerWizard.currentProvider().ID; got != "anthropic" {
t.Fatalf("after down, selected provider = %q, want anthropic", got)
}
@@ -313,15 +309,8 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
// Move off the recommended OpenGateway entry (index 0) onto openai so the
// OPENAI_API_KEY env credential is the one in play.
updated, _ := m.Update(testKey(tea.KeyDown))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if got := next.providerWizard.currentProvider().ID; got != "openai" {
t.Fatalf("after down, selected provider = %q, want openai", got)
}
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if next.providerWizard.step != providerWizardStepCredential {
t.Fatalf("enter from provider step = %v, want credential", next.providerWizard.step)
}
-12
View File
@@ -7,22 +7,10 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/term"
)
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
func Run(ctx context.Context, options Options) int {
// The interactive shell needs a real terminal on stdin: with piped or
// redirected input Bubble Tea blocks forever waiting for events that never
// arrive (e.g. `echo "" | zero`). Fail fast with guidance toward the headless
// path instead of hanging. term.IsTerminal is a true TTY check (it rejects
// pipes, regular files, and non-terminal char devices like /dev/null) and
// fails closed — anything that is not a verified terminal blocks the shell.
if !term.IsTerminal(os.Stdin.Fd()) {
fmt.Fprintln(os.Stderr, "zero: the interactive shell needs a terminal (stdin is not a TTY). For non-interactive use, run: zero exec \"<prompt>\"")
return 2
}
externalSink := options.RuntimeMessageSink
var program *tea.Program
options.RuntimeMessageSink = func(msg tea.Msg) {
+1 -34
View File
@@ -1,11 +1,6 @@
package tui
import (
"context"
"os"
"testing"
"time"
)
import "testing"
func TestUseAltScreenForInteractiveChat(t *testing.T) {
if !useAltScreen(Options{}) {
@@ -15,31 +10,3 @@ func TestUseAltScreenForInteractiveChat(t *testing.T) {
t.Fatal("setup takeover should also use the alternate screen")
}
}
// TestRunRejectsNonTTYStdin pins that the interactive shell fails fast with a
// non-zero code when stdin is not a terminal, instead of blocking forever in the
// Bubble Tea event loop (e.g. `echo "" | zero`). The guard runs before any model
// construction, so empty Options are fine.
func TestRunRejectsNonTTYStdin(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
w.Close() // a pipe read-end is not a character device
old := os.Stdin
os.Stdin = r
defer func() { os.Stdin = old; r.Close() }()
done := make(chan int, 1)
go func() { done <- Run(context.Background(), Options{}) }()
select {
case code := <-done:
if code != 2 {
t.Fatalf("Run with non-TTY stdin returned %d; want exit code 2 from the stdin TTY guard", code)
}
case <-time.After(5 * time.Second):
t.Fatal("Run blocked on non-TTY stdin instead of failing fast")
}
}
+7 -5
View File
@@ -261,11 +261,13 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
switch a.state {
case "done", "failed", "completed", "cancelled":
a.finishing = true
// A member drops once ITS OWN task completes — not when the whole turn
// ends: fade out over the linger window from when it was first seen
// finished (stamped each tick by stampSwarmDone), then remove. This
// holds whether or not the overall run is still in flight, mirroring
// how finished specialists drop (sidebarSpecialists).
if m.pending {
// Run still going: keep it visible and clickable, no fade.
a.finishedAt = time.Time{}
live = append(live, a)
continue
}
// Turn ended: fade out over the linger window, then drop.
doneAt, stamped := m.swarmDoneAt[a.id]
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
continue // past the linger window — remove
+9 -9
View File
@@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) {
// row (e.g. a resumed transcript that dropped the call): the member is still
// A finished member stays visible (and clickable) while the run is still in
// flight, so the user can inspect it; only once the turn ends does it drop.
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) {
func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
m := sidebarTestModel()
m.now = func() time.Time { return base }
m.pending = true // run STILL going — a member must still drop on its OWN completion
m.pending = true // run still going
m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7},
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7},
transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7},
)
// Just finished, still within the linger window: shows briefly with a fading ✓.
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
// Long past the linger window — but it must still show while pending.
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
agents := m.swarmSpawnedAgents()
if len(agents) != 1 {
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
}
if !agents[0].finishing {
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
}
// Past the linger window: it drops — even though the overall run is still in
// flight (previously a finished member lingered until the whole turn ended).
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)}
// Once the turn ends, the long-finished member fades out and drops.
m.pending = false
if got := len(m.swarmSpawnedAgents()); got != 0 {
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
}
}
-5
View File
@@ -86,7 +86,6 @@ type ProviderCatalogSnapshot struct {
Local bool `json:"local"`
RuntimeSupported bool `json:"runtimeSupported"`
RuntimeUnsupportedReason string `json:"runtimeUnsupportedReason,omitempty"`
Recommended bool `json:"recommended,omitempty"`
}
type SessionSnapshot struct {
@@ -219,9 +218,6 @@ func ProviderCatalogSnapshots(options ProviderCatalogSnapshotOptions) ([]Provide
snapshots = append(snapshots, ProviderCatalogSnapshotFromDescriptor(descriptor))
}
sort.SliceStable(snapshots, func(i int, j int) bool {
if snapshots[i].Recommended != snapshots[j].Recommended {
return snapshots[i].Recommended
}
return snapshots[i].ID < snapshots[j].ID
})
return snapshots, nil
@@ -248,7 +244,6 @@ func ProviderCatalogSnapshotFromDescriptor(descriptor providercatalog.Descriptor
Local: descriptor.Local,
RuntimeSupported: providercatalog.RuntimeSupported(descriptor),
RuntimeUnsupportedReason: providercatalog.RuntimeUnsupportedReason(descriptor),
Recommended: descriptor.Recommended,
}
}
+1 -6
View File
@@ -164,16 +164,11 @@ func TestProviderCatalogSnapshotsExposeStableDescriptors(t *testing.T) {
if len(snapshots) < 4 {
t.Fatalf("expected provider catalog descriptors, got %#v", snapshots)
}
if !snapshots[0].Recommended {
t.Fatalf("expected the recommended provider first, got %#v", snapshots[0])
}
for index, snapshot := range snapshots {
if snapshot.ID == "" || snapshot.Name == "" || snapshot.Transport == "" {
t.Fatalf("catalog snapshot missing identity fields: %#v", snapshot)
}
// The recommended provider is hoisted to the front; everything after it is
// sorted by id.
if index > 0 && !snapshots[index-1].Recommended && snapshots[index-1].ID > snapshot.ID {
if index > 0 && snapshots[index-1].ID > snapshot.ID {
t.Fatalf("provider catalog snapshots are not sorted by id: %#v", snapshots)
}
}
+1 -1
View File
@@ -38,5 +38,5 @@
"bugs": {
"url": "https://github.com/Gitlawb/zero/issues"
},
"license": "MIT"
"license": "SEE LICENSE IN LICENSE"
}
-13
View File
@@ -102,19 +102,6 @@ if (!platform || !arch) {
);
}
// The release matrix builds linux/macos {x64,arm64} and windows-x64 — there is no
// windows-arm64 artifact. (win32, arm64) otherwise resolves to a valid
// platform/arch, so it would proceed to download a non-existent asset and hard
// -fail npm install on a 404. Guard it explicitly to skip gracefully; Windows on
// ARM runs the x64 build under emulation, so the x64 package is the fallback.
if (platform === 'windows' && arch === 'arm64') {
warnSkip(
`no prebuilt binary for windows-arm64 (the windows-x64 build runs under ` +
`emulation on Windows on ARM). Build from source or install the x64 package: ` +
`https://github.com/${REPO} (go run ./cmd/zero).`,
);
}
const ext = platform === 'windows' ? 'zip' : 'tar.gz';
const binaryName = platform === 'windows' ? 'zero.exe' : 'zero';
const tag = `v${VERSION}`;