Compare commits

..
Author SHA1 Message Date
Gnanam cd7c2736dc fix: stream Codex reasoning + drop swarm agents on their own completion
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an
'agents never disappear' report.

A) Codex Responses path streamed no reasoning, so a long thinking phase produced
   zero visible output and read as a hang (the activity clock never advanced).
   Root cause: the request never asked for a reasoning summary and the parser had
   no case for reasoning deltas. Now request reasoning.summary="auto" and forward
   response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI
   handler renders it live and refreshes the activity clock.

B) A finished swarm member stayed in the AGENTS panel until the whole turn ended.
   Now it drops once ITS OWN task completes (fade over the linger window, then
   remove), independent of the overall run — matching how finished specialists
   already behave. Running members and mid-flight collect are unaffected.
2026-06-30 21:04:23 +05:30
59 changed files with 317 additions and 3795 deletions

No files matched your search

+1 -8
View File
@@ -11,14 +11,7 @@ tagged. Until then, source builds report the version `dev`.
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+3 -3
View File
@@ -254,9 +254,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
-19
View File
@@ -280,25 +280,6 @@ func isContextLimitError(message string) bool {
return false
}
// isStreamTimeoutError reports whether a streamed error is a provider stream
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
// no content was forwarded yet it can be safely re-issued on a fresh connection
// (see the stall-retry in the agent loop). Substring + case-insensitive to
// tolerate the prefixed "provider stream error: …" wrapping.
func isStreamTimeoutError(message string) bool {
lowered := strings.ToLower(strings.TrimSpace(message))
if lowered == "" {
return false
}
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
if strings.Contains(lowered, needle) {
return true
}
}
return false
}
// compactionState carries the per-run state the agent loop needs to compact a
// conversation safely. It is created once per Run and is a no-op whenever
// options.ContextWindow <= 0.
-99
View File
@@ -1,99 +0,0 @@
package agent
import "testing"
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
// into one arguments string by decoding the FIRST object and ignoring the rest
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
// (c) still reject a genuinely malformed/truncated first object.
func TestDecodeToolArguments(t *testing.T) {
t.Run("single object decodes normally", func(t *testing.T) {
var a map[string]any
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if a["path"] != "x" {
t.Fatalf("decoded %v, want path=x", a)
}
})
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
var b map[string]any
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
t.Fatalf("multi-object should decode the first, got err = %v", err)
}
if b["path"] != "first" {
t.Fatalf("decoded %v, want the first object (path=first)", b)
}
})
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
var c map[string]any
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
t.Fatalf("decoded %v, want only the first object's keys", c)
}
})
t.Run("truncated first object still errors", func(t *testing.T) {
var d map[string]any
if err := decodeToolArguments(`{"path":`, &d); err == nil {
t.Fatal("truncated JSON must still error")
}
})
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
var g map[string]any
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
t.Fatal("a valid first object followed by non-JSON garbage must still error")
}
})
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
cases := []struct {
in string
ok bool
first string
}{
{`{"a":1}`, true, `{"a":1}`},
{`{"a":1}{"b":2}`, true, `{"a":1}`},
{`{"a":1} {"b":2} `, true, `{"a":1}`},
{`{"a":1}xyz`, false, ""},
{`{"a":`, false, ""},
{` `, false, ""},
}
for _, c := range cases {
first, ok := recoverableToolArguments(c.in)
if ok != c.ok || (ok && first != c.first) {
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
}
}
})
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
out := historySafeToolCalls([]ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
})
if out[0].Arguments != `{"path":"README.md"}` {
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
}
if out[1].Arguments != "{}" {
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
}
if out[2].Arguments != `{"path":"ok"}` {
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
}
})
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
var e map[string]any
if err := decodeToolArguments(" ", &e); err == nil {
t.Fatal("whitespace-only must error")
}
})
}
+36 -151
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
@@ -21,12 +20,6 @@ import (
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
toolResultControlSpecReview = "spec_review_required"
@@ -210,72 +203,55 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
// never re-streams on top of output the user already saw — even across the
// reactive-compaction retry below, which overwrites `collected`.
forwardedAnything := false
mark := func() { forwardedAnything = true }
// OnUsage is telemetry, not visible output, so it does NOT mark
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
// tokens) then stalls with no content must still be safely retried. The gate
// only guards against re-streaming output the user actually SAW.
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
// stream: an image rejection gets the friendly wrapping, and a context limit
// gets one compaction + reactive reissue (omitting visible callbacks, since
// any pre-error output was already forwarded). It returns the possibly-updated
// collected and a non-nil stop error when the run must end now.
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
if isImageRejectionError(errors.New(collected.Error)) {
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnReasoning: options.OnReasoning,
OnUsage: options.OnUsage,
OnToolCallStart: options.OnToolCallStart,
OnToolCallDelta: options.OnToolCallDelta,
})
if collected.Error != "" {
collectedErr := errors.New(collected.Error)
if isImageRejectionError(collectedErr) {
result.Messages = copyMessages(messages)
return result, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
}
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
// REACTIVE compaction: the streamed error may also be a context
// limit (some providers surface it mid-stream). Compact and retry
// the same turn once before giving up.
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
messages = compacted
if retryErr != nil {
return collected, retryErr
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
return collected, retryStreamErr
result.Messages = copyMessages(messages)
return result, retryStreamErr
}
// Omit OnText on the reactive retry: when the original error
// surfaced MID-stream, partial text was already forwarded to the
// user. Re-streaming the retried response on top of it would
// duplicate output. OnUsage IS kept so token telemetry/budgeting
// still counts the successful retry. The retried text is captured
// in collected.Text and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
}
return collected, nil
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
if collected.Error != "" {
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
}
// Check ctx first: on cancellation helpers.go sets collected.Error to
// ctx.Err().Error(), so returning errors.New(collected.Error) would lose
@@ -284,45 +260,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout with NO output forwarded yet this turn can be
// safely re-issued on a fresh connection — nothing was shown, so there's no
// duplication (this recovers the macOS stale-pooled-connection hang when it
// slips past the transport's response-header timeout). A turn that already
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
// falls through to the error return below. Capped + exponential backoff.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
// rather than returning it raw.
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
// Carry the turn's terminal stop reason so a final answer cut off at the
@@ -675,20 +615,8 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
for i, call := range calls {
safe[i] = call
args := strings.TrimSpace(call.Arguments)
switch {
case args == "":
if args == "" || !json.Valid([]byte(args)) {
safe[i].Arguments = "{}"
case json.Valid([]byte(args)):
// already a single valid JSON value — keep as-is
default:
// Recover the first object for the concatenated case so the replayed
// transcript matches what executeToolCall actually ran; otherwise (real
// corruption) fall back to an empty object.
if first, ok := recoverableToolArguments(args); ok {
safe[i].Arguments = first
} else {
safe[i].Arguments = "{}"
}
}
}
return safe
@@ -713,53 +641,10 @@ func toolSchemaJSON(registry *tools.Registry, name string) string {
// is a non-nil ABORT error only when the call demands the whole run stop (a
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
// success or tool error returns a nil abort error.
// recoverableToolArguments inspects a tool call's raw JSON arguments. It returns
// the raw text of the FIRST JSON value when the payload is one value optionally
// followed by additional WHOLE JSON values (the weak-model concatenated case, e.g.
// `{A}{B}`). ok is false when the payload is genuinely malformed: a bad/truncated
// first value, OR trailing NON-JSON garbage after a valid first value (e.g.
// `{"x":1}xyz`) — that case must still error, not be silently accepted. Sharing
// this between dispatch and replay-history keeps them consistent: only a cleanly
// recoverable first object is ever used.
func recoverableToolArguments(arguments string) (first string, ok bool) {
dec := json.NewDecoder(strings.NewReader(arguments))
var head json.RawMessage
if err := dec.Decode(&head); err != nil {
return "", false
}
// The remainder must be only whole JSON values (or nothing); any decode error
// other than EOF means trailing garbage — reject so corruption still surfaces.
for {
var rest json.RawMessage
if err := dec.Decode(&rest); err != nil {
if errors.Is(err, io.EOF) {
return strings.TrimSpace(string(head)), true
}
return "", false
}
}
}
// decodeToolArguments decodes a tool call's JSON arguments into v. It tolerates a
// weaker model that packs MULTIPLE concatenated top-level JSON objects into one
// arguments string (`{A}{B}`) — the cause of "invalid character '{' after top-level
// value" failures with small models like minimax-m3 — by decoding the FIRST object
// and ignoring the trailing WHOLE JSON values, so the primary intended call runs.
// A genuinely malformed payload (bad/truncated first object, or non-JSON trailing
// garbage) still returns the real parse error; a standard single-object payload
// decodes exactly as before.
func decodeToolArguments(arguments string, v any) error {
if first, ok := recoverableToolArguments(arguments); ok {
return json.Unmarshal([]byte(first), v)
}
// Not cleanly recoverable — surface the genuine parse error.
return json.Unmarshal([]byte(arguments), v)
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
+1 -51
View File
@@ -1053,10 +1053,7 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
// Genuinely malformed (truncated) args: still error -> sanitize -> retry.
// (Concatenated multi-object args are now tolerated; see
// TestRunRecoversFirstObjectFromConcatenatedToolArgs.)
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -1106,53 +1103,6 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
}
}
// A weak model (e.g. minimax-m3) that packs MULTIPLE concatenated JSON objects into
// one tool-call slot must RECOVER the first object and run the call, not fail with
// "invalid character '{' after top-level value". The trailing object(s) are dropped
// (the model re-issues them on a later turn if still needed).
func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello readme"), 0o644); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "read", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
var toolResult string
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResult = message.Content
}
}
if strings.Contains(toolResult, "Failed to parse arguments") {
t.Fatalf("concatenated args should recover the first object, not fail to parse: %q", toolResult)
}
if !strings.Contains(toolResult, "hello readme") {
t.Fatalf("expected README.md contents (the first object's path), got %q", toolResult)
}
}
func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
// A single assistant turn with two tool calls where the first mutates and
// self-correct fails must keep both tool_results contiguous, with the feedback
-122
View File
@@ -1,122 +0,0 @@
package agent
import (
"context"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". When
// partialText is set it emits that text BEFORE the stall, simulating a stall after
// partial output (which must NOT be retried — re-issuing would duplicate it).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 3)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
}
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 10m (the model produced nothing)",
}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// A no-output stall is re-issued on a fresh connection and recovers — this is the
// macOS stale-pooled-connection hang turned into an automatic recovery.
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a no-output stall should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
// the already-streamed text. It surfaces the error instead.
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1, partialText: "partial"}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a stall after partial output must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
}
}
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
// retried: the user already saw the reasoning, so re-issuing with callbacks
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
// the turn-level forwarded-output gate specifically, not the text check.
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
_, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err == nil {
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
}
}
// A persistent stall surfaces an error after exhausting the capped retries.
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
p := &stallProvider{stallBefore: 99}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a persistent stall must surface an error after exhausting retries")
}
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
}
}
func TestIsStreamTimeoutError(t *testing.T) {
timeouts := []string{
"provider stream error: no output for 10m (the model produced nothing)",
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
"stream stalled (upstream kept the connection alive but produced no output)",
}
for _, m := range timeouts {
if !isStreamTimeoutError(m) {
t.Fatalf("want timeout-classified: %q", m)
}
}
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
for _, m := range notTimeouts {
if isStreamTimeoutError(m) {
t.Fatalf("must NOT classify as timeout: %q", m)
}
}
}
+12 -12
View File
@@ -217,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -641,7 +641,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -1034,10 +1033,9 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1045,7 +1043,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1055,10 +1053,12 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
+1 -1
View File
@@ -1397,7 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
@@ -1,49 +0,0 @@
package config
import (
"os"
"testing"
)
// SetActiveProviderEnv must export the provider name so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) resolves the SAME active provider. A blank name CLEARS it.
func TestSetActiveProviderEnvRoundTrips(t *testing.T) {
t.Setenv(ActiveProviderEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(ActiveProviderEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetActiveProviderEnv("") // blank with nothing set stays unset
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("empty name set the env to %q, want unset", got)
}
// Regression: switching from a named provider back to an unnamed/default profile
// must CLEAR a previously exported value, not leave it stale for children.
if err := os.Setenv(ActiveProviderEnv, "stale-provider"); err != nil {
t.Fatalf("seed stale: %v", err)
}
SetActiveProviderEnv("") // blank must unset the stale value
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("blank name left stale env %q, want cleared", got)
}
cfgStale := &FileConfig{}
applyEnv(cfgStale, nil)
if cfgStale.ActiveProvider != "" {
t.Fatalf("applyEnv still resolved stale provider %q after clear", cfgStale.ActiveProvider)
}
SetActiveProviderEnv("ollama-cloud")
if got := os.Getenv(ActiveProviderEnv); got != "ollama-cloud" {
t.Fatalf("env = %q, want ollama-cloud", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value selects the active provider.
cfg := &FileConfig{}
applyEnv(cfg, nil)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("applyEnv ActiveProvider = %q, want ollama-cloud (inherited env not read)", cfg.ActiveProvider)
}
}
-56
View File
@@ -1,56 +0,0 @@
package config
import (
"os"
"testing"
)
// SetMaxTurnsEnv must export the per-run turn budget so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) runs with the SAME budget the user set via /turns. n <= 0 is a no-op,
// and a non-numeric/zero env value must not clobber the configured budget.
func TestSetMaxTurnsEnvRoundTrips(t *testing.T) {
t.Setenv(MaxTurnsEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(MaxTurnsEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetMaxTurnsEnv(0) // non-positive must not set anything
if got := os.Getenv(MaxTurnsEnv); got != "" {
t.Fatalf("n<=0 set the env to %q, want unset", got)
}
SetMaxTurnsEnv(150)
if got := os.Getenv(MaxTurnsEnv); got != "150" {
t.Fatalf("env = %q, want 150", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value sets the budget.
cfg := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg, nil)
if cfg.MaxTurns != 150 {
t.Fatalf("applyEnv MaxTurns = %d, want 150 (inherited env not read)", cfg.MaxTurns)
}
// A garbage env value must leave the configured budget untouched.
if err := os.Setenv(MaxTurnsEnv, "not-a-number"); err != nil {
t.Fatalf("set: %v", err)
}
cfg2 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg2, nil)
if cfg2.MaxTurns != defaultMaxTurns {
t.Fatalf("garbage env clobbered MaxTurns to %d, want default %d", cfg2.MaxTurns, defaultMaxTurns)
}
// An over-ceiling env value (e.g. a raw shell export that bypasses the /turns
// clamp) must be clamped to MaxTurnsCeiling at the read site.
if err := os.Setenv(MaxTurnsEnv, "999999"); err != nil {
t.Fatalf("set: %v", err)
}
cfg3 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg3, nil)
if cfg3.MaxTurns != MaxTurnsCeiling {
t.Fatalf("applyEnv MaxTurns = %d, want clamped to ceiling %d", cfg3.MaxTurns, MaxTurnsCeiling)
}
}
+2 -56
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -14,15 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
// too low for real multi-step agentic work (agents ran out mid-task before reaching
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
const defaultMaxTurns = 50
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
// command (write site) so the bound holds even if the env is set by a raw shell.
const MaxTurnsCeiling = 500
const defaultMaxTurns = 30
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
// Zero collapses their full JSON schemas into compact `tool_search` reminder
@@ -195,9 +186,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
}
@@ -443,53 +431,11 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
return base
}
// ActiveProviderEnv selects the active provider profile by name (read in applyEnv).
const ActiveProviderEnv = "ZERO_PROVIDER"
// SetActiveProviderEnv exports the active provider name to the process environment
// so a spawned child process (which inherits the environment) resolves the SAME
// provider profile — and therefore the same credentials (env key / stored key /
// OAuth) — as its parent. Without this a sub-agent re-resolves config.json's
// default provider and can land on one whose credentials don't match the parent's
// live selection, failing auth the instant it spawns. A blank name CLEARS the
// variable: switching back to an unnamed/default profile must not keep exporting a
// stale provider to children.
func SetActiveProviderEnv(name string) {
name = strings.TrimSpace(name)
if name == "" {
_ = os.Unsetenv(ActiveProviderEnv)
return
}
_ = os.Setenv(ActiveProviderEnv, name)
}
// MaxTurnsEnv overrides the per-run tool-turn budget by name (read in applyEnv).
const MaxTurnsEnv = "ZERO_MAX_TURNS"
// SetMaxTurnsEnv exports the per-run tool-turn budget to the process environment so
// a spawned child (sub-agent / swarm member, which inherits the environment) runs
// with the SAME budget the user set via /turns. Without it a child re-resolves
// config.json's default and a large delegated task can exhaust its turns mid-run
// (exit 4 / max-turns). No-op for n <= 0.
func SetMaxTurnsEnv(n int) {
if n > 0 {
_ = os.Setenv(MaxTurnsEnv, strconv.Itoa(n))
}
}
func applyEnv(cfg *FileConfig, env map[string]string) {
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
if activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
if maxTurns := strings.TrimSpace(envValue(env, MaxTurnsEnv)); maxTurns != "" {
if n, err := strconv.Atoi(maxTurns); err == nil && n > 0 {
if n > MaxTurnsCeiling {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
}
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
+2 -2
View File
@@ -883,8 +883,8 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
if resolved.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
}
}
-4
View File
@@ -97,10 +97,6 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,omitempty"`
// Theme is the persisted TUI palette preference — "auto" or a registered theme
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
-22
View File
@@ -154,28 +154,6 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
func normalizeFavoriteModels(models []string) []string {
seen := map[string]bool{}
favorites := make([]string, 0, len(models))
-33
View File
@@ -272,39 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
cfg, err := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+2 -2
View File
@@ -211,11 +211,11 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
-7
View File
@@ -264,13 +264,6 @@ func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
}
func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// Isolate the OAuth token store to an empty temp path so the factory reads no
// stored login — otherwise this test picks up the developer's real chatgpt
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
+2 -2
View File
@@ -158,10 +158,10 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, &state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
+2 -2
View File
@@ -389,10 +389,10 @@ func (p *CodexProvider) streamResponses(
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, inner.streamIdleTimeout, func(data string) bool {
return p.emitResponsesEvent(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: p.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, inner.streamIdleTimeout)),
Error: p.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", inner.streamIdleTimeout)),
})
return
}
+6 -7
View File
@@ -102,11 +102,10 @@ func New(options Options) (*Provider, error) {
endpoint = baseURL + "/chat/completions"
}
// Route through providerio.HTTPClient so a missing client gets the shared,
// stall-hardened transport (bounded response-header wait + shorter idle-conn
// reuse) rather than the raw http.DefaultClient, which hangs on a reused-dead
// pooled connection (the macOS-only "both providers stall" bug).
httpClient := providerio.HTTPClient(options.HTTPClient)
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
maxTokens := options.MaxTokens
if maxTokens < 0 {
@@ -219,12 +218,12 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
+12 -94
View File
@@ -23,26 +23,6 @@ const maxSSELineBytes = 16 * 1024 * 1024
// closing the connection. Callers surface it as an idle-timeout error.
var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
// error (ErrStreamIdle or ErrStreamStalled), given the configured idle timeout.
// Callers prepend their own "provider stream error: " prefix. A stalled stream
// gets a distinct, actionable message — it did NOT stop sending data (keep-alives
// kept arriving); it just produced no output, which usually means the model is
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
// DefaultStreamIdleTimeout is the single source of truth for how long every
// provider waits on a silent stream before aborting it. The watchdog only fires
// on genuine silence — SSE keep-alive comments reset it (see
@@ -53,14 +33,6 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -105,42 +77,12 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
return baseURL, nil
}
// sharedHTTPClient is the process-wide client used when a provider supplies none.
// It tunes the default transport to defeat the stale-pooled-connection hang: Go
// keeps idle keep-alive connections in a pool, and a later request can reuse one
// the server/NAT has silently dropped. Because the model call is a POST (non-
// idempotent), Go will NOT auto-retry it on a fresh connection — so it blocks
// forever waiting for a response that never arrives. This is provider-agnostic
// (every provider shares the default transport), which is why it reproduced on
// BOTH chatgpt and ollama, and it surfaces on macOS far more than Linux because
// macOS keeps dead pooled connections around longer.
//
// - ResponseHeaderTimeout bounds the gap between finishing the request and the
// first response header, so a reused-dead connection fails fast (then the
// reconnect path re-dials) instead of hanging. It does NOT bound streaming
// after the 200 header arrives, so slow first tokens / long reasoning are
// unaffected (the stream-idle + content watchdogs cover that).
// - IdleConnTimeout is shortened so a connection that went idle across a pause
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
var sharedHTTPClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
// 200 response header until the upstream model emits a first token, so a 60s cap
// risked aborting a legitimately-slow-but-alive request. 120s still bounds a
// truly dead reused connection (which never responds) while tolerating slow
// header delivery; slow first tokens after the header are covered by the idle +
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
return &http.Client{Transport: transport}
}()
// HTTPClient returns the configured client or the shared, stall-hardened default.
// HTTPClient returns the configured client or the process default.
func HTTPClient(client *http.Client) *http.Client {
if client != nil {
return client
}
return sharedHTTPClient
return http.DefaultClient
}
// SendEvent writes a provider event without blocking cancellation cleanup.
@@ -260,36 +202,21 @@ func ScanSSEDataWithContext(
// The idle watchdog is optional. When idleTimeout <= 0 it is disabled, but we
// STILL run the goroutine + select loop so ctx cancellation is always honored
// (a nil idleC channel simply never fires in the select).
var idleC, contentC <-chan time.Time
var idleC <-chan time.Time
resetIdle := func() {}
resetContent := func() {}
if idleTimeout > 0 {
// reset drains the timer's channel if it already fired before re-arming, so
// a stale tick can't trip the watchdog after activity resumed.
reset := func(t *time.Timer, d time.Duration) func() {
return func() {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
}
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
resetIdle = reset(idle, idleTimeout)
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := idleTimeout * streamContentStallFactor
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
resetContent = reset(content, contentTimeout)
resetIdle = func() {
if !idle.Stop() {
select {
case <-idle.C:
default:
}
}
idle.Reset(idleTimeout)
}
}
for {
@@ -305,12 +232,6 @@ func ScanSSEDataWithContext(
// a timeout instead of blocking the agent forever.
cancel()
return ErrStreamIdle
case <-contentC:
// Upstream kept heartbeating (idle watchdog never fired) but produced
// no output for the content window. Treat as stalled and abort rather
// than hanging forever.
cancel()
return ErrStreamStalled
case item, ok := <-payloads:
if !ok {
// Reader finished: deliver its terminal status (EOF -> nil,
@@ -325,11 +246,8 @@ func ScanSSEDataWithContext(
}
resetIdle()
if item.keepAlive {
// A heartbeat keeps the connection "alive" (idle watchdog) but is
// NOT output, so it must not reset the content watchdog.
continue
}
resetContent()
if !handle(item.data) {
// The provider asked to stop (e.g. it already emitted an error
// for this payload). Abort the read and end like ScanSSEData:
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
@@ -202,133 +201,3 @@ func TestUpstreamUnreachable(t *testing.T) {
})
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
stop := make(chan struct{})
defer close(stop)
go func() {
// Heartbeat every 30ms — comfortably under the 100ms idle timeout, so idle
// never fires under CI/GC jitter — but never send a data line.
for {
select {
case <-stop:
return
default:
}
if _, err := io.WriteString(pw, ": keep-alive\n\n"); err != nil {
return
}
time.Sleep(30 * time.Millisecond)
}
}()
cancelled := false
cancel := func() { cancelled = true }
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamStalled) {
t.Fatalf("err = %v, want ErrStreamStalled", err)
}
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a heartbeat-but-no-output stream")
}
if !cancelled {
t.Fatal("content stall did not cancel the request context")
}
}
// Real data lines reset the content watchdog, so a slow-but-producing stream that
// runs longer than the content window is never aborted.
func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 200ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
return
}
time.Sleep(30 * time.Millisecond)
}
_ = pw.Close()
}()
n := 0
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("err = %v, want nil (data kept the stream alive)", err)
}
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
}
if n != 10 {
t.Fatalf("handled %d data lines, want 10", n)
}
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
}
}
// HTTPClient(nil) must return the shared, stall-hardened transport (bounded
// response-header wait + shorter idle-conn reuse) that defeats the macOS stale-
// pooled-connection hang; an explicit client is returned untouched.
func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
got := HTTPClient(nil)
if got == nil {
t.Fatal("HTTPClient(nil) returned nil")
}
tr, ok := got.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T, want *http.Transport", got.Transport)
}
if tr.ResponseHeaderTimeout != 120*time.Second {
t.Fatalf("ResponseHeaderTimeout = %v, want 120s", tr.ResponseHeaderTimeout)
}
if tr.IdleConnTimeout != 30*time.Second {
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
}
if HTTPClient(nil) != got {
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
}
custom := &http.Client{}
if HTTPClient(custom) != custom {
t.Fatal("an explicit client must be returned unchanged")
}
}
+5 -39
View File
@@ -204,11 +204,7 @@ type ChildRunResult struct {
Events []streamjson.Event
Stderr string
ExitCode int
// Signal is a human-readable description (e.g. "signal: killed") when the child
// was terminated by a signal rather than exiting normally; empty otherwise. It
// turns an opaque exit -1 into an actionable reason (SIGKILL ~ out of memory).
Signal string
Started bool
Started bool
}
func (executor Executor) Run(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
@@ -570,35 +566,17 @@ func (executor Executor) runBuiltArgs(ctx context.Context, built BuildArgsResult
exitCode := run.exitCodeOr(-1)
summary := SummarizeStream(run.Events, exitCode)
executor.recordSpecialistStop(accounting, summary, "error", summary.ExitCode, err, false)
// Carry the child session id even on a post-start failure so a caller (the
// swarm launcher -> FailWithSession) can still make the failed member
// drillable; the session exists once the child has started.
return ExecResult{SessionID: built.SessionID}, err
return ExecResult{}, err
}
summary := SummarizeStream(run.Events, run.ExitCode)
rolledUp := executor.rollUpSpecialistUsage(accounting, summary)
executor.recordSpecialistStop(accounting, summary, summary.Status, summary.ExitCode, nil, rolledUp)
return ExecResult{
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal),
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode),
SessionID: built.SessionID,
}, nil
}
// availableSpecialistList renders the registered specialist names for a corrective
// "not found" error, so a model that guessed a wrong name learns the real options.
func availableSpecialistList(result LoadResult) string {
names := make([]string, 0, len(result.Specialists))
for _, manifest := range result.Specialists {
if n := strings.TrimSpace(manifest.Metadata.Name); n != "" {
names = append(names, n)
}
}
if len(names) == 0 {
return "(none registered)"
}
return strings.Join(names, ", ")
}
func (executor Executor) loadManifest(name string) (Manifest, error) {
name = strings.TrimSpace(name)
if name == "" {
@@ -614,12 +592,7 @@ func (executor Executor) loadManifest(name string) (Manifest, error) {
}
manifest, ok := Find(result, name)
if !ok {
// Corrective error: a model that invents a specialist name (e.g.
// "validator-runner", "file-writer") otherwise gets an opaque "not found"
// and keeps retrying made-up names. List the ACTUALLY-registered ones so it
// self-corrects — no hardcoded names, since a custom registry may not have
// worker/explorer/code-review.
return Manifest{}, fmt.Errorf("specialist %q not found. Available: %s. Pick one of these whose tools fit the task, or omit the name to use the default", name, availableSpecialistList(result))
return Manifest{}, fmt.Errorf("specialist %q not found", name)
}
return manifest, nil
}
@@ -810,22 +783,15 @@ func runChildProcess(ctx context.Context, binaryPath string, args []string, prog
}
exitCode := 0
started := true
signalDesc := ""
if err := command.Wait(); err != nil {
var exitErr *osexec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
if exitCode < 0 && exitErr.ProcessState != nil {
// ExitCode() is -1 when the child was terminated by a signal rather
// than exiting. ProcessState.String() is the portable description
// (e.g. "signal: killed") — capture it so the failure isn't opaque.
signalDesc = exitErr.ProcessState.String()
}
} else {
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: -1, Started: started}, fmt.Errorf("run specialist child: %w", err)
}
}
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Signal: signalDesc, Started: started}, nil
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Started: started}, nil
}
func (run ChildRunResult) exitCodeOr(defaultExitCode int) int {
@@ -1,32 +0,0 @@
package specialist
import (
"strings"
"testing"
)
// A model that invents a specialist name (e.g. "validator-runner", "file-writer")
// must get a corrective error listing the real specialists, so it self-corrects to a
// capable one instead of looping on made-up names that spawn doomed sub-agents.
func TestLoadManifestUnknownNameListsAvailable(t *testing.T) {
executor := Executor{
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{
{Metadata: Metadata{Name: "worker"}},
{Metadata: Metadata{Name: "explorer"}},
{Metadata: Metadata{Name: "code-review"}},
}}, nil
},
}
_, err := executor.loadManifest("validator-runner")
if err == nil {
t.Fatal("an unknown specialist name must error")
}
msg := err.Error()
for _, want := range []string{"validator-runner", "not found", "worker", "explorer", "code-review"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q (must steer the model to a real specialist)", msg, want)
}
}
}
-6
View File
@@ -260,12 +260,6 @@ func formatTaskOutputSummary(task background.Task, summary StreamResult, rawLine
if !task.CompletedAt.IsZero() {
fmt.Fprintf(&builder, "completed_at: %s\n", task.CompletedAt.Format(time.RFC3339))
fmt.Fprintf(&builder, "exit_code: %d\n", task.ExitCode)
if task.ExitCode < 0 {
// A negative exit code means the background child was terminated by a
// signal rather than exiting (the int-only launcher can't carry the
// signal name). Give the same actionable hint the foreground path does.
builder.WriteString("note: terminated by a signal — killed before it finished. Likely causes: the OS out-of-memory killer (common when many sub-agents run in parallel — try fewer), a timeout, or cancellation.\n")
}
}
if summary.Text != "" {
+2 -15
View File
@@ -134,17 +134,12 @@ func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResul
return result
}
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int, signalDesc string) tools.Result {
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int) tools.Result {
summary := SummarizeStream(events, processExitCode)
hasErrors := len(summary.Errors) > 0 || summary.ExitCode != 0
if summary.Status != "" && summary.Status != "success" && summary.Status != "ok" {
hasErrors = true
}
// A captured kill signal must always surface, even if a late run_end reported a
// clean exit just before the child was killed during teardown.
if strings.TrimSpace(signalDesc) != "" {
hasErrors = true
}
if !hasErrors {
output := summary.Text
if summary.SessionID != "" {
@@ -154,15 +149,7 @@ func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExi
}
var builder strings.Builder
if signal := strings.TrimSpace(signalDesc); signal != "" {
// The child was terminated by a signal (exit code -1) rather than exiting.
// Surface the signal instead of an opaque "exit -1", and list the common
// causes evenhandedly — this branch also covers timeouts and cancellations,
// so don't assert OOM.
fmt.Fprintf(&builder, "Subagent terminated by a signal (%s) — it was killed before it finished. Common causes: an out-of-memory kill, a timeout, or cancellation; check the signal to tell which. If you were running many sub-agents in parallel, reduce the concurrency.\n", signal)
} else {
fmt.Fprintf(&builder, "Subagent failed (exit %d)\n", summary.ExitCode)
}
fmt.Fprintf(&builder, "Subagent failed (exit %d)\n", summary.ExitCode)
if len(summary.Errors) > 0 {
fmt.Fprintf(&builder, "errors: %s\n", strings.Join(summary.Errors, "; "))
}
+3 -30
View File
@@ -25,7 +25,7 @@ func TestParseStreamAndBuildFinalResultSuccess(t *testing.T) {
if summary.SessionID != "child" || summary.Text != "done" || summary.ExitCode != 0 || len(summary.Tools) != 1 || summary.Tools[0] != "grep" {
t.Fatalf("unexpected summary: %#v", summary)
}
result := BuildFinalResult(events, "", 0, "")
result := BuildFinalResult(events, "", 0)
if result.Status != tools.StatusOK || result.Output != "session_id: child\ndone" {
t.Fatalf("unexpected final result: %#v", result)
}
@@ -41,7 +41,7 @@ func TestBuildFinalResultUsesTextDeltasWhenFinalMissing(t *testing.T) {
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "", 0, "")
result := BuildFinalResult(events, "", 0)
if result.Status != tools.StatusOK || result.Output != "session_id: child\nhello world" {
t.Fatalf("unexpected final result: %#v", result)
}
@@ -82,7 +82,7 @@ func TestBuildFinalResultErrorIncludesDiagnostics(t *testing.T) {
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "stderr text", 0, "")
result := BuildFinalResult(events, "stderr text", 0)
if result.Status != tools.StatusError {
t.Fatalf("Status = %s, want error", result.Status)
}
@@ -93,33 +93,6 @@ func TestBuildFinalResultErrorIncludesDiagnostics(t *testing.T) {
}
}
// A child terminated by a signal (exit code -1) must surface the signal + an
// actionable hint, not an opaque "Subagent failed (exit -1)".
func TestBuildFinalResultSurfacesKillSignal(t *testing.T) {
events, err := ParseStream(strings.NewReader(strings.Join([]string{
`{"schemaVersion":2,"type":"run_start","runId":"run_1","sessionId":"child"}`,
`{"schemaVersion":2,"type":"tool_call","runId":"run_1","id":"call_1","name":"read_file"}`,
"",
}, "\n")))
if err != nil {
t.Fatalf("ParseStream returned error: %v", err)
}
result := BuildFinalResult(events, "", -1, "signal: killed")
if result.Status != tools.StatusError {
t.Fatalf("Status = %s, want error", result.Status)
}
// Surfaces the signal + lists causes (OOM/timeout/cancellation) without asserting
// OOM as the sole cause.
for _, want := range []string{"terminated by a signal", "signal: killed", "out-of-memory", "timeout", "cancellation"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("kill output missing %q:\n%s", want, result.Output)
}
}
if strings.Contains(result.Output, "Subagent failed (exit") {
t.Fatalf("signaled child should not show the opaque exit line:\n%s", result.Output)
}
}
func TestParseStreamRejectsInvalidLines(t *testing.T) {
_, err := ParseStream(strings.NewReader(`{"schemaVersion":2,"runId":"run_1"}` + "\n"))
if err == nil || !strings.Contains(err.Error(), "type is required") {
-6
View File
@@ -166,12 +166,6 @@ func (c *Coordinator) Fail(id, errMsg string) error {
return c.finish(id, StatusFailed, "", errMsg, "")
}
// FailWithSession marks a task failed but keeps its child session id, so a member
// that ran and failed (e.g. exited non-zero / hit max-turns) is still drillable.
func (c *Coordinator) FailWithSession(id, errMsg, sessionID string) error {
return c.finish(id, StatusFailed, "", errMsg, sessionID)
}
func (c *Coordinator) finish(id string, status TaskStatus, result, errMsg, sessionID string) error {
c.mu.Lock()
defer c.mu.Unlock()
+1 -14
View File
@@ -2,10 +2,8 @@ package swarm
import (
"context"
"errors"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/tools"
)
// NewSpecialistLauncher adapts internal/specialist.Executor into a
@@ -56,18 +54,7 @@ func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher {
MemberAutonomy: true,
})
if err != nil {
// Preserve the child session id on a post-start failure too (exec.go
// returns it on the error path) so lifecycle's FailWithSession can keep
// the failed member drillable, not just the StatusError case below.
return MemberResult{SessionID: res.SessionID}, err
}
if res.Result.Status == tools.StatusError {
// The child ran but its task FAILED (e.g. non-zero exit / max-turns).
// Surface it as a member failure so the swarm marks it [failed], not
// [done] — otherwise the orchestrator (which can't see the AGENTS panel)
// trusts incomplete work. Keep the session id so the failed member is
// still drillable, and carry its report as the failure message.
return MemberResult{SessionID: res.SessionID}, errors.New(res.Result.Output)
return MemberResult{}, err
}
return MemberResult{Result: res.Result.Output, SessionID: res.SessionID}, nil
}}
@@ -67,50 +67,3 @@ func TestSpecialistLauncherRunsUnregisteredSwarmAgent(t *testing.T) {
t.Fatalf("member args missing agent type: %#v", gotArgs)
}
}
// A member whose child exits non-zero (e.g. exit 4 / max-turns) must be reported as
// a FAILURE — otherwise the swarm marks it [done] and the orchestrator trusts
// incomplete work. The failed member keeps its session id (drill-in) and the child
// report rides along as the failure message.
func TestSpecialistLauncherMarksNonZeroExitAsFailed(t *testing.T) {
four := 4
executor := specialist.Executor{
BinaryPath: "/usr/local/bin/zero",
NewSessionID: func() (string, error) { return "member_task", nil },
Load: func(specialist.LoadOptions) (specialist.LoadResult, error) {
return specialist.LoadResult{}, nil
},
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (specialist.ChildRunResult, error) {
return specialist.ChildRunResult{
Events: []streamjson.Event{
{Type: streamjson.EventRunStart, SessionID: "member_task"},
{Type: streamjson.EventFinal, Text: "i could not finish the objective"},
{Type: streamjson.EventRunEnd, Status: "error", ExitCode: &four},
},
ExitCode: 4,
}, nil
},
}
handle, err := NewSpecialistLauncher(executor).Launch(context.Background(), MemberSpec{
ID: "m1",
TaskID: "m1",
AgentType: "subagent",
Team: "probe",
Task: "a task too big for the budget",
SystemPrompt: "You are a subagent spawned to complete a specific task.",
})
if err != nil {
t.Fatalf("launch: %v", err)
}
res, err := handle.Wait()
if err == nil {
t.Fatal("a member that exited non-zero must be reported as FAILED, got nil error")
}
if !strings.Contains(err.Error(), "exit 4") {
t.Fatalf("failure should carry the child report (exit 4), got %q", err.Error())
}
if res.SessionID != "member_task" {
t.Fatalf("a failed member must keep its session id for drill-in, got %q", res.SessionID)
}
}
+1 -3
View File
@@ -71,9 +71,7 @@ func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
}
// fall through to record the original error if relaunch fails
}
// res.SessionID is preserved by the handle even on error, so a member that
// ran then failed stays drillable; a pure launch error carries an empty id.
_ = s.coord.FailWithSession(m.TaskID, memberError(err), res.SessionID)
_ = s.coord.Fail(m.TaskID, memberError(err))
} else {
_ = s.coord.CompleteWithSession(m.TaskID, res.Result, res.SessionID)
}
+2 -21
View File
@@ -103,25 +103,6 @@ func errResult(format string, a ...any) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "Error: " + fmt.Sprintf(format, a...)}
}
// agentTypeProperty builds the schema for an agent-type parameter, constraining it
// to the roster's currently registered types (the built-ins plus any user-registered
// agents) via an enum so a model can't spawn or hand off to an unknown type — the
// schema teaches the valid set up front instead of the model learning it from a
// failed call. Falls back to a free-form string if the roster is unavailable or
// empty (so the tool never advertises an empty, un-satisfiable enum).
func agentTypeProperty(sw *Swarm, description string) tools.PropertySchema {
prop := tools.PropertySchema{Type: "string", Description: description}
if sw == nil {
return prop
}
if registry := sw.Registry(); registry != nil {
if types := registry.AgentTypes(); len(types) > 0 {
prop.Enum = types
}
}
return prop
}
// ---- swarm_spawn -----------------------------------------------------------
type spawnTool struct {
@@ -137,7 +118,7 @@ func (t *spawnTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"agent_type": agentTypeProperty(t.sw, "Roster agent type to spawn."),
"agent_type": {Type: "string", Description: "Roster agent type to spawn (e.g. teammate, subagent)."},
"task": {Type: "string", Description: "The focused task/briefing for the member."},
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
},
@@ -372,7 +353,7 @@ func (t *handoffTool) Parameters() tools.Schema {
Type: "object",
Properties: map[string]tools.PropertySchema{
"task_id": {Type: "string", Description: "Task id to hand off."},
"to_agent_type": agentTypeProperty(t.sw, "Roster agent type to take over."),
"to_agent_type": {Type: "string", Description: "Roster agent type to take over."},
"note": {Type: "string", Description: "Optional handoff note delivered to the new member's inbox."},
"team": {Type: "string", Description: "Team the task belongs to. Defaults to \"default\"."},
},
-41
View File
@@ -2,7 +2,6 @@ package swarm
import (
"context"
"slices"
"strings"
"testing"
"time"
@@ -11,46 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/tools"
)
func TestSpawnAndHandoffAgentTypeEnumReflectsRoster(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
want := sw.Registry().AgentTypes() // sorted built-ins: subagent, teammate
spawnEnum := (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
if !slices.Equal(spawnEnum, want) {
t.Fatalf("spawn agent_type enum = %v, want roster %v", spawnEnum, want)
}
if !slices.Contains(spawnEnum, "subagent") || !slices.Contains(spawnEnum, "teammate") {
t.Fatalf("enum missing built-in roster types: %v", spawnEnum)
}
handoffEnum := (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
if !slices.Equal(handoffEnum, want) {
t.Fatalf("handoff to_agent_type enum = %v, want roster %v", handoffEnum, want)
}
// A user-registered custom type must extend the enum (not a hardcoded list).
if err := sw.Registry().Register(Definition{AgentType: "researcher"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
spawnEnum = (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
if !slices.Contains(spawnEnum, "researcher") {
t.Fatalf("custom agent type not reflected in spawn enum: %v", spawnEnum)
}
handoffEnum = (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
if !slices.Contains(handoffEnum, "researcher") {
t.Fatalf("custom agent type not reflected in handoff enum: %v", handoffEnum)
}
}
func TestAgentTypePropertyFallsBackWithoutRoster(t *testing.T) {
prop := agentTypeProperty(nil, "desc")
if prop.Type != "string" || prop.Description != "desc" {
t.Fatalf("unexpected property: %+v", prop)
}
if prop.Enum != nil {
t.Fatalf("nil swarm must not advertise an (empty) enum, got %v", prop.Enum)
}
}
func TestCollapseRuneSafeTruncation(t *testing.T) {
// 300 multi-byte runes: truncation must not split a rune (no invalid UTF-8).
got := collapse(strings.Repeat("🚀", 300))
+54 -134
View File
@@ -28,17 +28,8 @@ const (
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
)
type execSessionManager struct {
@@ -242,10 +233,6 @@ type ExecSessionSnapshot struct {
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionController interface {
@@ -330,26 +317,24 @@ func (session *execSession) snapshot() ExecSessionSnapshot {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
mu sync.Mutex
data []byte
recent []byte
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
@@ -359,10 +344,6 @@ func newExecOutputBuffer() *execOutputBuffer {
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
@@ -392,34 +373,6 @@ func (buffer *execOutputBuffer) drainString() string {
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -532,28 +485,25 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
output += session.collect(context.Background(), time.Second)
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -608,39 +558,26 @@ func (tool execCommandTool) startSession(commandText string, absoluteCwd string,
return session, nil
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
func (session *execSession) collect(ctx context.Context, wait time.Duration) string {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
return builder.String()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
return builder.String()
}
timer := time.NewTimer(remaining)
select {
@@ -665,9 +602,9 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
default:
}
}
return finish()
return builder.String()
case <-timer.C:
return finish()
return builder.String()
}
}
}
@@ -764,23 +701,22 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
}
}
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: session.commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -805,22 +741,16 @@ func shouldInterruptExecSession(chars string, tty bool) bool {
}
type execToolResultInput struct {
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
commandText string
output string
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
@@ -841,26 +771,16 @@ func execToolResult(input execToolResultInput) Result {
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Truncated: truncated,
Meta: meta,
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
-87
View File
@@ -363,93 +363,6 @@ func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
result := execToolResult(execToolResultInput{
commandText: "cmd",
output: hugeOutput,
outputBufferTruncated: true,
sessionID: 1,
exited: false,
maxOutputTokens: 1, // the schema's own declared minimum
})
if !strings.Contains(result.Output, execOutputBufferTruncatedMessage) {
t.Fatalf("result output should contain the buffer-truncation notice even at the smallest max_output_tokens, got %q", result.Output[:min(len(result.Output), 200)])
}
if result.Meta["output_buffer_truncated"] != "true" {
t.Fatalf("result meta should flag output_buffer_truncated, got %#v", result.Meta)
}
if !result.Truncated {
t.Fatal("result should report Truncated when the buffer dropped output")
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
-3
View File
@@ -146,9 +146,6 @@ func formatBackgroundTerminalRow(session tools.ExecSessionSnapshot, now time.Tim
}
preview := compactCommandOutputText(session.RecentOutput)
prefix := fmt.Sprintf("%d · %s · %s · %s", session.ID, session.Status, age, cwd)
if session.OutputTruncated {
prefix += " · output truncated"
}
if preview != "" {
return prefix + " · " + command + " · " + preview
}
-4
View File
@@ -441,8 +441,6 @@ func (m model) handleModelCommand(args string) (model, string) {
m.providerProfile = nextProfile
m.provider = nextProvider
m.providerName = displayValue(nextProfile.Name, string(metadata.ProviderKind))
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(nextProfile.Name)
m.modelName = target.modelID
resetEffort := false
if m.reasoningEffort != "" && !reasoningEffortAllowed(target.reasoningEfforts, m.reasoningEffort) {
@@ -518,8 +516,6 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
m.providerProfile = target
m.providerName = target.Name
m.modelName = target.Model
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(target.Name)
if strings.TrimSpace(m.userConfigPath) != "" {
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
+2 -10
View File
@@ -40,7 +40,6 @@ const (
commandImage
commandAddDir
commandSelfCorrect
commandTurns
commandUnknown
)
@@ -242,13 +241,6 @@ var commandDefinitions = []commandDefinition{
description: "Show or set post-edit self-correction depth (LSP-only default; on/tests/full add the project test plan; off/lsp disable tests, LSP-only).",
kind: commandSelfCorrect,
},
{
name: "/turns",
usage: "/turns [n]",
group: commandGroupSession,
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
kind: commandTurns,
},
{
name: "/help",
usage: "/help",
@@ -281,9 +273,9 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/theme",
usage: "/theme [list|auto|name]",
usage: "/theme [auto|dark|light]",
group: commandGroupSession,
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
description: "Switch or show the color theme (auto-detects terminal background).",
kind: commandTheme,
},
{
-4
View File
@@ -139,10 +139,6 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
// surface ended.
func (m *model) resetFlushFrontier(divider string) {
m.flushed = 0
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
// /compact); a transcript-hover target's bodyY would otherwise risk
// coincidentally matching an unrelated row in the rebuilt transcript.
m.hover = hoverTarget{}
if divider != "" && m.flushedAny {
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
}
-117
View File
@@ -1,117 +0,0 @@
package tui
import tea "charm.land/bubbletea/v2"
// hoverKind discriminates which clickable surface a hoverTarget refers to.
type hoverKind int
const (
hoverNone hoverKind = iota
// hoverTranscript: a specialist card or collapse/expand toggle header in the
// main transcript body (or the subchat child view), identified by bodyY.
hoverTranscript
// hoverSidebarAgent: a clickable AGENTS sidebar row, identified by sessionID.
hoverSidebarAgent
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
// step index.
hoverPlanStep
)
// hoverTarget identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed. Exactly one of bodyY/sessionID/stepIndex is
// meaningful, discriminated by kind.
//
// The sidebar fields deliberately store a STABLE IDENTITY (sessionID, stepIndex),
// not a raw line offset: the AGENTS/PLAN section sizes can change between when a
// hover is detected and when the sidebar next renders (a swarm member's linger
// window elapsing, a plan step completing) with no mouse motion in between to
// re-resolve it. A cached raw offset would then silently point at whatever
// unrelated row now occupies that slot. hoveredSidebarLineOffset (sidebar.go)
// re-resolves the CURRENT line offset for this identity fresh on every render, so
// a row that's gone simply doesn't highlight rather than a wrong one lighting up.
type hoverTarget struct {
kind hoverKind
bodyY int // hoverTranscript
sessionID string // hoverSidebarAgent
stepIndex int // hoverPlanStep
}
// mouseHover reports whether msg is a plain cursor-movement event with NO button
// pressed — a hover, not a drag. Requires AllMotion mouse reporting (see View's
// wantsMouseCapture branch); CellMotion only reports motion while a button is held,
// so this predicate would never match under the old mouse mode.
func mouseHover(msg tea.MouseMsg) bool {
if _, ok := msg.(tea.MouseMotionMsg); !ok {
return false
}
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
// nothing further to set here.
if line.permOption {
return m.hoverPermissionOption(line.permChoice).withHover(hoverTarget{})
}
// Only a card or a collapse/expand toggle header is "clickable" here; plain
// selectable text (e.g. a user/assistant message) isn't, so hovering over
// it must not light up as if it were.
if line.specialistCard || line.toggle {
return m.withHover(hoverTarget{kind: hoverTranscript, bodyY: line.bodyY})
}
}
return m.withHover(hoverTarget{})
}
// hoverPermissionOption moves the pending permission prompt's cursor to the
// hovered option, reusing its EXISTING keyboard-navigation highlight (the same
// one Tab/Shift+Tab/arrow keys move) instead of new styling — the popup already
// renders whichever option .cursor points at differently. A no-op when no
// permission prompt is pending or the choice isn't found (defensive; every
// permOption line's choice always comes from permissionOptions(request) in the
// first place).
func (m model) hoverPermissionOption(choice permissionDecision) model {
if m.pendingPermission == nil {
return m
}
for index, option := range permissionOptions(m.pendingPermission.request) {
if option.choice == choice {
m.pendingPermission.cursor = index
return m
}
}
return m
}
func (m model) withHover(t hoverTarget) model {
m.hover = t
return m
}
// clearHover drops any hover highlight. Called wherever the underlying content a
// hover target refers to may no longer mean the same thing on the next render — a
// wheel-scroll (the cursor's mapping to a bodyY is only recomputed on the NEXT
// motion event; without clearing, a stale bodyY could coincidentally match a
// DIFFERENT row after the viewport shifts, highlighting the wrong thing) or a
// subchat toggle (bodyY numbering is entirely different between the parent
// transcript and a child session).
func (m model) clearHover() model {
if m.hover.kind == hoverNone {
return m
}
m.hover = hoverTarget{}
return m
}
-284
View File
@@ -1,284 +0,0 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
)
func TestMouseHoverPredicate(t *testing.T) {
if !mouseHover(testMouseMotion(tea.MouseNone, 5, 5)) {
t.Error("a motion event with no button pressed must be a hover")
}
if mouseHover(testMouseMotion(tea.MouseLeft, 5, 5)) {
t.Error("a motion event WITH a button pressed is a drag, not a hover")
}
if mouseHover(testMouseClick(tea.MouseLeft, 5, 5)) {
t.Error("a click is not a hover")
}
if mouseHover(testMouseWheel(tea.MouseWheelDown, 5, 5)) {
t.Error("a wheel event is not a hover")
}
}
func TestUpdateHoverTargetOnSpecialistCard(t *testing.T) {
m := specialistClickTestModel(t, "child-sess-1")
x, y, ok := specialistCardClickPoint(m, 2)
if !ok {
t.Fatal("expected a specialist card selectable line in the layout")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatalf("hover.kind = %v, want hoverTranscript over a specialist card", m.hover.kind)
}
}
func TestUpdateHoverTargetOnToggleRow(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.toggle {
target = line
break
}
}
if !target.toggle {
t.Fatalf("expected reasoning header to be clickable, selectable=%#v", selectable)
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatalf("hover.kind = %v, want hoverTranscript over a toggle header", m.hover.kind)
}
if m.hover.bodyY != target.bodyY {
t.Fatalf("hover.bodyY = %d, want %d", m.hover.bodyY, target.bodyY)
}
}
func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
m := mouseTestModel()
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseMotion(tea.MouseNone, 3, textY))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("hover.kind = %v, want hoverNone over plain (non-clickable) text", m.hover.kind)
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
func TestUpdateHoverTargetOnPermissionOptionMovesCursor(t *testing.T) {
m := mouseTestModel()
m.pending = true // the permission prompt only renders into the transcript body while m.pending
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "write_file"},
cursor: 0,
}
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
found := false
for _, line := range selectable {
if line.permOption && line.permChoice != permissionOptions(m.pendingPermission.request)[0].choice {
target = line
found = true
break
}
}
if !found {
t.Fatal("expected at least 2 permission options to test a non-default hover target")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
// Permission-option hover reuses the existing cursor highlight, not m.hover.
if m.hover.kind != hoverNone {
t.Fatalf("hover.kind = %v, want hoverNone (permission options use pendingPermission.cursor)", m.hover.kind)
}
wantIndex := -1
for index, option := range permissionOptions(m.pendingPermission.request) {
if option.choice == target.permChoice {
wantIndex = index
break
}
}
if m.pendingPermission.cursor != wantIndex {
t.Fatalf("pendingPermission.cursor = %d, want %d (the hovered option)", m.pendingPermission.cursor, wantIndex)
}
}
func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
m := specialistClickTestModel(t, "child-sess-1")
width := m.chatColumnWidth()
without := m.transcriptBodyLayout(width, "").String()
x, y, ok := specialistCardClickPoint(m, 2)
if !ok {
t.Fatal("expected a specialist card selectable line in the layout")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
m = updated.(model)
if m.hover.kind != hoverTranscript {
t.Fatal("sanity check failed: hover should be set over the specialist card")
}
with := m.transcriptBodyLayout(width, "").String()
if without == with {
t.Fatal("rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
updated, _ := m.Update(testMouseWheel(tea.MouseWheelUp, 0, 3))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("hover should clear on a wheel scroll, got %#v", m.hover)
}
}
func TestHoverClearsOnSubchatExit(t *testing.T) {
m := mouseTestModel()
m.subchat.active = true
m.subchat.childSessionID = "child-1"
m.subchat.childRows = appendRow(nil, rowUser, "child text")
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
m = updated.(model)
if m.subchat.active {
t.Fatal("Esc should exit the subchat view")
}
if m.hover.kind != hoverNone {
t.Fatalf("hover should clear on subchat exit (bodyY numbering differs), got %#v", m.hover)
}
}
// A sidebar row can disappear between when it was hovered and the next render
// (a swarm member's linger window elapsing) with no mouse motion in between to
// re-target the hover. hoveredSidebarLineOffset must not paint a highlight on
// whatever unrelated row now occupies that identity's old slot — it must find
// nothing and skip painting entirely.
func TestHoveredSidebarLineOffsetSelfHealsWhenRowDisappears(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
m.hover = hoverTarget{kind: hoverSidebarAgent, sessionID: hits[0].sessionID}
// Simulate the member disappearing (a fresh model with no swarm member at all,
// same identity no longer present) without any mouse motion clearing m.hover.
m2 := swarmSidebarTestModel(t, nil)
m2.hover = m.hover
if _, ok := m2.hoveredSidebarLineOffset(sidebarWidth(m2.width)); ok {
t.Fatal("hoveredSidebarLineOffset should find nothing once the identity no longer resolves, not fall back to a stale/coincidental row")
}
}
+12 -136
View File
@@ -40,13 +40,6 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
// tick size (chatWheelScrollLines) would jump too far per step for that.
const dragEdgeScrollInterval = 70 * time.Millisecond
const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
@@ -287,31 +280,10 @@ type model struct {
// (clickable suggestions, right-click paste, transcript select) pause while on.
mouseReleased bool
transcriptSelection transcriptSelectionState
// hover identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
// (see wantsMouseCapture) since idle cursor movement carries no button.
hover hoverTarget
copyStatus string
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
// tea.Tick chain (see dragEdgeScrollTickCmd) keeps stepping it at a fixed small
// increment regardless of whether new raw mouse-motion events arrive — a
// terminal only reports motion on actual cursor movement, so without a timer
// the scroll would stop dead the instant the physical mouse holds still, even
// while parked past the edge. edgeScrollSeq invalidates a stale in-flight tick
// (mirroring exitConfirmSeq/copyStatusSeq) whenever the drag moves back into
// the body, releases, or the chain is otherwise stopped.
edgeScrollDelta int
edgeScrollSeq int
// edgeScrollMouseX is the column the tick chain keeps extending the selection
// at — captured from the triggering drag since a timer tick carries no mouse
// position of its own.
edgeScrollMouseX int
copyStatus string
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// picker, when non-nil, is an open interactive selector overlay (/model,
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
@@ -358,15 +330,6 @@ type exitConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
// tick was scheduled) and is silently dropped — the self-scheduling chain simply
// doesn't reschedule itself, so it terminates rather than ticking forever.
type dragEdgeScrollTickMsg struct {
seq int
}
// toolCallStreamStartMsg / toolCallStreamDeltaMsg carry a tool call's live
// argument stream from the agent goroutine to the update loop, so a file being
// written renders as it streams (see streamingToolCallView).
@@ -678,7 +641,7 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
@@ -927,12 +890,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
}
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
case providerWizardOAuthMsg:
return m.applyProviderWizardOAuth(msg)
case providerWizardDeviceCodeMsg:
@@ -999,7 +956,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
if m.mcpCommandCancel != nil {
@@ -1047,11 +1003,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
if m.picker.kind == pickerTheme {
// A live theme preview was applied while navigating; restore the
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
m.picker = nil
return m, nil
}
@@ -1186,9 +1137,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.deleteQueryRune()
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -1226,17 +1174,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.transcriptDetailed {
return m, nil
}
// A stationary mouse over a bodyY-keyed transcript hover target would
// otherwise stay lit at the scrolled-away bodyY until the next real
// motion event (see clearHover) — same reasoning as the wheel-scroll
// cases in mouse.go.
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
if m.transcriptDetailed {
return m, nil
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
@@ -1261,7 +1203,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -1278,7 +1220,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// ArrowUp exits subchat view (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
if m.transcriptDetailed {
@@ -1303,7 +1244,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -1362,9 +1303,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if keyPrintable(msg) {
m.picker.appendQuery(keyRunes(msg))
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
}
return m, nil
}
@@ -1507,10 +1445,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// A resize re-wraps content at a new width, shifting every row's bodyY;
// a stale transcript-hover target could coincidentally land on an
// unrelated clickable row (same reasoning as clearHover's other callers).
m = m.clearHover()
// Reset the streaming-text fade state. A width change can re-wrap
// the in-progress text into a different number of visual lines,
// which invalidates the per-line age mapping. The next delta
@@ -1945,27 +1879,9 @@ func (m model) View() tea.View {
view := tea.NewView(content)
view.AltScreen = m.altScreen
// Paint the whole frame with the active theme's surface. Zero never paints the
// terminal's own canvas, so without this a theme's text falls on the terminal
// background — fine when they share polarity, but a light theme's dark text on a
// dark terminal (or vice versa) is invisible, and a color theme never shows its
// real surface. Painting the panel makes every theme self-contained and legible
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
// picker) too. Alt-screen only, so inline output never leaves a painted
// background behind in the user's scrollback after exit.
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
// AllMotion (not CellMotion) is required for hover highlighting: it
// reports cursor movement even with no button pressed. CellMotion only
// reports motion while a button is held (drag) — see bubbletea's
// MouseMode docs. AllMotion has marginally worse terminal compatibility
// but is well supported by the terminals this app targets; the existing
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
// the redraw rate from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
view.MouseMode = tea.MouseModeCellMotion
}
return view
}
@@ -2114,14 +2030,10 @@ func (m model) footerView(width int) string {
// while the transcript scrolls underneath (a streaming turn no longer pushes
// the plan off-screen). Budgeted to at most a third of the screen height; a
// taller plan collapses to a one-line summary so the composer always stays
// on screen. Skipped in the subchat drill-in: m.plan belongs to the PARENT
// run, not the subagent/swarm child session being viewed there, so pinning it
// above that composer would show unrelated state.
if !m.subchat.active {
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
footer.WriteString(plan)
footer.WriteString("\n")
}
// on screen.
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
footer.WriteString(plan)
footer.WriteString("\n")
}
// The row above the composer: transient copy feedback takes priority; otherwise
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
@@ -3287,11 +3199,6 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
item, ok := picker.current()
if !ok {
if picker.kind == pickerTheme {
// No selectable row (e.g. the filter matched nothing): undo any live
// preview so the palette matches the committed m.themeMode.
m.restoreCommittedTheme()
}
return m, nil
}
switch picker.kind {
@@ -3316,18 +3223,6 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
text := ""
m, text = m.handleThemeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so a committed `auto` re-detects
// light/dark instead of reusing the preview's reading — mirrors the
// text /theme dispatch (M17).
return m, tea.RequestBackgroundColor
}
}
return m, nil
}
@@ -3570,26 +3465,7 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleSelfCorrectCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTurns:
// Changing the budget mid-run would mutate the inherited ZERO_MAX_TURNS env
// that sub-agents spawned later in THIS run read, making the run's budget
// inconsistent. Require an idle session (the new budget applies next run).
if m.pending && strings.TrimSpace(command.text) != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Turns\nFinish or stop the current run before changing the tool-turn budget."})
return m, nil
}
text := ""
m, text = m.handleTurnsCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
// Bare `/theme` opens the popup picker (live preview on move, apply on
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
// (or `/theme list`) still runs the text handler directly.
if strings.TrimSpace(command.text) == "" {
m.picker = m.newThemePicker()
return m, nil
}
text := ""
m, text = m.handleThemeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
+4 -34
View File
@@ -14,25 +14,6 @@ type mouseSelectionTarget struct {
Index int
}
// scrollChatExtendingSelection scrolls the chat and, if a drag-selection is in
// progress, extends its cursor to the mouse's current position against the
// POST-scroll viewport — mirroring what a mouseMotion event does. Without this, a
// selection's cursor is only ever updated by actual mouse movement (see
// handleTranscriptSelectionMouse's mouseMotion case): a wheel-scroll event is a
// distinct message type that never reaches that code, so scrolling while
// selecting left the selection frozen at whatever line the mouse last physically
// moved over — capped at the viewport that was visible before the scroll.
func (m model) scrollChatExtendingSelection(delta int, msg tea.MouseMsg) model {
m = m.scrollChat(delta)
if !m.transcriptSelection.active {
return m
}
if line, ok := m.nearestTranscriptLineAtMouse(msg); ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
}
return m
}
func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// While a provider-wizard OAuth login is in flight, ignore all mouse input
// (clicks and wheel) so a stray scroll can't change the selected provider
@@ -103,17 +84,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if next, cmd, ok := m.handleTranscriptSelectionMouse(msg); ok {
return next, cmd
}
// A plain hover (cursor moved, no button pressed) never matches the
// press/drag/release cases above, so it falls through here — resolve what's
// under the cursor so it can render with the hover highlight.
if mouseHover(msg) {
return m.updateHoverTarget(msg), nil
}
switch {
case mouseWheelUp(msg):
m.clearMouseSelection()
m = m.clearHover()
if m.providerWizard != nil {
m.providerWizard.move(-1)
return m, nil
@@ -130,7 +104,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
@@ -142,10 +116,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return next, nil
}
}
return m.scrollChatExtendingSelection(chatWheelScrollLines, msg), nil
return m.scrollChat(chatWheelScrollLines), nil
case mouseWheelDown(msg):
m.clearMouseSelection()
m = m.clearHover()
if m.providerWizard != nil {
m.providerWizard.move(1)
return m, nil
@@ -162,7 +135,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
@@ -174,7 +147,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return next, nil
}
}
return m.scrollChatExtendingSelection(-chatWheelScrollLines, msg), nil
return m.scrollChat(-chatWheelScrollLines), nil
default:
return m, nil
}
@@ -441,9 +414,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
-239
View File
@@ -407,245 +407,6 @@ func TestTranscriptSelectionReleaseExtendsRangeWithoutMotion(t *testing.T) {
}
}
// A completed selection stays "active" through the async copy-command grace
// window (transcriptCopiedMsg hasn't landed yet), but the drag itself is over.
// A genuine hover motion (no button held) arriving in that window must NOT be
// treated as a drag continuation — it must fall through to hover handling
// instead of silently moving the selection the user just released.
func TestTranscriptHoverAfterReleaseDoesNotMoveSelection(t *testing.T) {
m := withSidebarContent(mouseTestModel())
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
m = updated.(model)
updated, cmd := m.Update(testMouseRelease(tea.MouseNone, 8, textY))
m = updated.(model)
if cmd == nil {
t.Fatal("sanity check failed: release should return the copy command")
}
if m.transcriptSelection.dragging {
t.Fatal("dragging must be false immediately after release")
}
before := m.selectedTranscriptText()
// A hover (no button) moves further along the same line, in the window
// before transcriptCopiedMsg has landed (m.transcriptSelection.active is
// still true here).
updated, _ = m.Update(testMouseMotion(tea.MouseNone, 10, textY))
m = updated.(model)
if got := m.selectedTranscriptText(); got != before {
t.Fatalf("a post-release hover changed the selection: got %q, want unchanged %q", got, before)
}
}
// Dragging a selection past the top edge of the visible transcript must
// auto-scroll toward older content and extend the selection to follow — the
// classic "drag past the viewport edge keeps scrolling" affordance.
func TestTranscriptSelectionDragPastTopEdgeAutoScrolls(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
// This test exercises the animated glide specifically; reducedMotion
// defaults from TTY detection (see defaultReducedMotion), which differs
// between a local dev shell and CI's non-TTY runners — set it explicitly
// so the test is deterministic regardless of environment.
m.reducedMotion = false
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("selection should be active after a left click on transcript text")
}
cursorBefore := m.transcriptSelection.cursor.bodyY
scrollBefore := m.chatScrollOffset
frame, _, _ := m.transcriptHitTestLayout()
aboveBody := frame.bodyRect.y - 1 // one row above the visible transcript body
// A left-button drag (not a hover) moved past the top edge: the smooth-glide
// chain must start immediately (first step + a scheduled tick).
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if cmd == nil {
t.Fatal("crossing the top edge should schedule the glide tick chain")
}
if m.edgeScrollDelta <= 0 {
t.Fatalf("edgeScrollDelta = %d, want > 0 (scrolling toward older content)", m.edgeScrollDelta)
}
if m.chatScrollOffset == scrollBefore {
t.Fatal("dragging past the top edge should auto-scroll toward older content")
}
// A single small step may land on a blank spacer row between messages (no
// text there yet) — drive the tick chain forward like a sustained real hold
// would, and the cursor must eventually reach a genuinely earlier line.
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY >= cursorBefore; i++ {
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
m = updated.(model)
}
if !m.transcriptSelection.active {
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
}
if m.transcriptSelection.cursor.bodyY >= cursorBefore {
t.Fatalf("selection cursor bodyY = %d, want < %d (it must extend upward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
}
}
// Symmetric to the top-edge case: dragging past the BOTTOM edge scrolls toward
// newer content. Requires scrolling up first so there's somewhere to scroll back
// down to (chatScrollOffset=0 already sits at the bottom).
func TestTranscriptSelectionDragPastBottomEdgeAutoScrolls(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
m.chatScrollOffset = chatWheelScrollLines * 3 // scrolled up, away from the bottom
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("selection should be active after a left click on transcript text")
}
cursorBefore := m.transcriptSelection.cursor.bodyY
scrollBefore := m.chatScrollOffset
frame, _, _ := m.transcriptHitTestLayout()
belowBody := frame.bodyRect.y + frame.bodyRect.height // one row below the visible transcript body
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, belowBody))
m = updated.(model)
if cmd == nil {
t.Fatal("crossing the bottom edge should schedule the glide tick chain")
}
if m.edgeScrollDelta >= 0 {
t.Fatalf("edgeScrollDelta = %d, want < 0 (scrolling toward newer content)", m.edgeScrollDelta)
}
if m.chatScrollOffset >= scrollBefore {
t.Fatal("dragging past the bottom edge should auto-scroll toward newer content (offset decreases)")
}
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY <= cursorBefore; i++ {
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
m = updated.(model)
}
if !m.transcriptSelection.active {
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
}
if m.transcriptSelection.cursor.bodyY <= cursorBefore {
t.Fatalf("selection cursor bodyY = %d, want > %d (it must extend downward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
}
}
// The glide tick chain must self-terminate the instant the drag moves back into
// the visible body — otherwise a stray scheduled tick could keep scrolling after
// the user has already dragged back to the content they wanted selected.
func TestTranscriptSelectionEdgeGlideStopsWhenDragReturnsToBody(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
frame, _, _ := m.transcriptHitTestLayout()
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, frame.bodyRect.y-1))
m = updated.(model)
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: the glide chain should be running")
}
seqWhileGliding := m.edgeScrollSeq
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, textY))
m = updated.(model)
if m.edgeScrollDelta != 0 {
t.Fatalf("edgeScrollDelta = %d, want 0 (dragging back into the body must stop the glide)", m.edgeScrollDelta)
}
// A tick already in flight from before the drag returned must be a no-op —
// it carries the OLD seq, which no longer matches.
scrollBefore := m.chatScrollOffset
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: seqWhileGliding})
m = updated.(model)
if m.chatScrollOffset != scrollBefore {
t.Fatal("a stale in-flight tick must not scroll after the glide was stopped")
}
}
// A keypress mid-glide clears m.transcriptSelection.active directly (the
// KeyPressMsg handler in model.go), NOT through stopEdgeScroll — so
// edgeScrollDelta is left stale. If a fresh drag past the SAME edge afterward
// doesn't reset it, startEdgeScroll's "already running" fast path is fooled into
// thinking a chain is already active and silently does nothing: no immediate
// step, no scheduled tick. The press handler must reset glide state so every new
// drag starts clean regardless of how the previous one ended.
func TestTranscriptSelectionEdgeGlideRecoversAfterKeypressInterrupt(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
frame, _, _ := m.transcriptHitTestLayout()
aboveBody := frame.bodyRect.y - 1
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: the glide chain should be running")
}
// A keypress interrupts the drag WITHOUT releasing the mouse — clears
// transcriptSelection.active directly, bypassing stopEdgeScroll.
updated, _ = m.Update(testKey('a'))
m = updated.(model)
if m.transcriptSelection.active {
t.Fatal("sanity check failed: the keypress should have cleared the selection")
}
if m.edgeScrollDelta == 0 {
t.Fatal("sanity check failed: edgeScrollDelta should still be stale here (that's the bug this test guards)")
}
// A brand-new press+drag past the SAME edge must glide normally, not be
// silently swallowed by the stale state from the interrupted drag. The
// viewport shifted from the first glide's step, so the topmost visible text
// line's on-screen Y must be recomputed — reusing the ORIGINAL textY would
// fail for an unrelated reason (it may now land on a blank spacer row).
textY = topmostVisibleTranscriptMouseY(t, m)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("sanity check failed: the new press should start a fresh selection")
}
scrollBefore := m.chatScrollOffset
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
m = updated.(model)
if cmd == nil {
t.Fatal("the new drag should schedule a fresh glide tick chain, not be fooled by stale state")
}
if m.chatScrollOffset == scrollBefore {
t.Fatal("the new drag should have applied its immediate first scroll step")
}
}
func TestTranscriptSelectionClearsAfterCopy(t *testing.T) {
m := mouseTestModel()
m.transcriptSelection = transcriptSelectionState{active: true}
+4 -8
View File
@@ -51,14 +51,10 @@ type Options struct {
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
// Theme is the operator's palette preference: "auto" (default), a built-in
// ("dark"/"light"), or a registered color theme. Set from the --theme flag;
// falls back to ZERO_THEME, then the persisted SavedTheme, then auto.
Theme string
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// Theme is the operator's palette preference: "auto" (default), "dark", or
// "light". Set from the --theme flag; falls back to ZERO_THEME then auto.
Theme string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
-42
View File
@@ -23,7 +23,6 @@ const (
pickerModel pickerKind = iota
pickerEffort
pickerSession
pickerTheme
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -727,44 +726,3 @@ func (m model) newEffortPicker() *commandPicker {
}
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
if m.picker == nil {
return
}
m.picker.move(delta)
m.previewSelectedTheme()
}
+4 -7
View File
@@ -639,17 +639,14 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
func TestThemeCommandOpensNoPicker(t *testing.T) {
// /theme keeps the existing shell-only message; no picker opens.
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
if m.picker != nil {
t.Fatal("/theme should not open a picker")
}
}
-37
View File
@@ -9,7 +9,6 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
@@ -257,42 +256,6 @@ func (m model) handleSelfCorrectCommand(args string) (model, string) {
return m, m.selfCorrectText()
}
// maxTurnsCeiling caps the per-session /turns budget so a typo (e.g. /turns 99999)
// can't set an absurd ceiling; real multi-step tasks fit comfortably under it. Shared
// with config so applyEnv enforces the same bound on an inherited ZERO_MAX_TURNS.
const maxTurnsCeiling = config.MaxTurnsCeiling
func (m model) handleTurnsCommand(args string) (model, string) {
args = strings.TrimSpace(args)
if args == "" || strings.EqualFold(args, "status") {
return m, m.turnsText()
}
n, err := strconv.Atoi(args)
if err != nil || n < 1 {
return m, "Turns\nUsage: /turns <n> — set the per-run tool-turn budget for this session (a positive number, e.g. /turns 150)."
}
if n > maxTurnsCeiling {
n = maxTurnsCeiling
}
m.agentOptions.MaxTurns = n
// Propagate the budget to spawned sub-agents / swarm members (which inherit the
// environment) so a delegated task gets the same budget, not config.json's default.
config.SetMaxTurnsEnv(n)
return m, m.turnsText()
}
func (m model) turnsText() string {
return renderCommandOutput(commandOutput{
Title: "Turns",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "State",
Lines: []string{fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns)},
}},
Hints: []string{fmt.Sprintf("/turns <n> sets this session's tool-turn budget (max %d); raise it for long multi-step tasks like delegation/swarm runs", maxTurnsCeiling)},
})
}
func (m model) selfCorrectText() string {
depth := "LSP diagnostics only — fast, scoped to changed files"
if m.selfCorrectTests {
-35
View File
@@ -17,7 +17,6 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -608,15 +607,6 @@ func (m model) renderContextSidebar(width, height int) []string {
}
add(tokenLine)
// Hover highlight: resolved by STABLE IDENTITY (sessionID / stepIndex), not a
// cached line offset — see hoveredSidebarLineOffset. A row whose identity no
// longer resolves (it disappeared since the hover was last set from a real
// mouse motion) simply doesn't highlight, rather than a coincidentally-matching
// unrelated row lighting up.
if lineOffset, ok := m.hoveredSidebarLineOffset(width); ok && lineOffset >= 0 && lineOffset < len(lines) {
lines[lineOffset] = zeroTheme.hover.Render(ansi.Strip(lines[lineOffset]))
}
// Normalize every row to exactly width cells.
for i := range lines {
lines[i] = padStyledLine(lines[i], width)
@@ -630,31 +620,6 @@ func (m model) renderContextSidebar(width, height int) []string {
return lines
}
// hoveredSidebarLineOffset resolves the hovered sidebar row's CURRENT line offset
// fresh on every call, by re-matching m.hover's stable identity (sessionID for an
// agent, stepIndex for a plan step) against a freshly computed hit list — never a
// cached index. Returns false when the hover isn't sidebar-scoped, or when the
// identity no longer resolves (that row disappeared since the hover was last set
// by a real mouse motion — a linger window elapsing, a plan step completing —
// with no intervening motion to re-target it).
func (m model) hoveredSidebarLineOffset(width int) (int, bool) {
switch m.hover.kind {
case hoverSidebarAgent:
for _, hit := range m.sidebarAgentSelectables(width) {
if hit.sessionID == m.hover.sessionID {
return hit.lineOffset, true
}
}
case hoverPlanStep:
for _, hit := range m.sidebarPlanSelectables(width) {
if hit.stepIndex == m.hover.stepIndex {
return hit.lineOffset, true
}
}
}
return 0, false
}
// sidebarHeader renders a bold-muted uppercase section label. Bold muted (vs the
// faint body items and placeholders) gives the header enough weight to read as a
// section heading rather than more filler. The width arg is unused — kept so it
-118
View File
@@ -1,118 +0,0 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
// The subchat drill-in (viewing a subagent/swarm child session) swaps the on-screen
// transcript to m.subchat.childRows, but mouse hit-testing and the footer's pinned
// plan panel were never made subchat-aware — they kept operating on/showing the
// PARENT run's state while the child session was on screen. These tests guard the
// fix: mouse selection must resolve against the child rows, the plan panel must not
// show in that view, and a selection in progress must extend across a wheel-scroll
// instead of freezing at whatever was visible when the drag started.
func TestFooterHidesPlanPanelDuringSubchat(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 30
width := m.chatColumnWidth()
withoutSubchat := m.footerView(width)
if !strings.Contains(withoutSubchat, "Step number 1 here") {
t.Fatalf("sanity check failed: pinned plan panel should render outside subchat, got:\n%s", withoutSubchat)
}
m.subchat.active = true
m.subchat.childSessionID = "child-1"
withSubchat := m.footerView(width)
if strings.Contains(withSubchat, "Step number 1 here") {
t.Fatalf("plan panel must NOT show while viewing a subchat child session, got:\n%s", withSubchat)
}
}
func TestTranscriptSelectionInSubchatUsesChildSessionRows(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
// Parent transcript has DIFFERENT text than the child session: if selection
// hit-tests against the wrong (parent) rows, this proves it by either matching
// nothing or matching the wrong text.
m.transcript = appendRow(m.transcript, rowUser, "parent transcript text")
m.subchat.active = true
m.subchat.childSessionID = "child-1"
m.subchat.childRows = appendRow(nil, rowUser, "hello world")
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
m = updated.(model)
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, textY))
m = updated.(model)
if got := m.selectedTranscriptText(); got != "hello" {
t.Fatalf("selectedTranscriptText() in subchat = %q, want hello (from the CHILD session's rows)", got)
}
}
func TestTranscriptSelectionExtendsAcrossWheelScroll(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
// Enough rows that the transcript overflows the viewport, so a wheel-scroll is
// a real (non-clamped) scroll. chatScrollOffset=0 anchors to the BOTTOM (newest
// content, see transcriptViewport.window: start = totalLines-height-offset), so
// with overflow content the topmost VISIBLE line is not the transcript's first
// line — topmostVisibleTranscriptMouseY (window-aware) finds it correctly.
for i := 0; i < 80; i++ {
m.transcript = appendRow(m.transcript, rowUser, "line content")
}
textY := topmostVisibleTranscriptMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
m = updated.(model)
if !m.transcriptSelection.active {
t.Fatal("selection should be active after a left click on transcript text")
}
cursorBefore := m.transcriptSelection.cursor.bodyY
scrollBefore := m.chatScrollOffset
// Wheel UP reveals OLDER content above (offset increases -> window.start
// decreases): re-evaluating the SAME on-screen Y afterward must land on an
// EARLIER (smaller bodyY) line now that the viewport has shifted, extending the
// selection upward instead of leaving the cursor pinned to the pre-scroll line.
updated, _ = m.Update(testMouseWheel(tea.MouseWheelUp, 0, textY))
m = updated.(model)
if m.chatScrollOffset == scrollBefore {
t.Fatal("sanity check failed: wheel-up should have scrolled (80 rows overflow a 30-row terminal)")
}
if !m.transcriptSelection.active {
t.Fatal("selection must survive a wheel-scroll, not be cleared")
}
if m.transcriptSelection.cursor.bodyY >= cursorBefore {
t.Fatalf("selection cursor bodyY = %d, want < %d (it must extend to follow the scroll, not freeze)", m.transcriptSelection.cursor.bodyY, cursorBefore)
}
}
// topmostVisibleTranscriptMouseY returns the on-screen Y of the topmost currently
// VISIBLE selectable text line — window-aware (unlike firstTranscriptTextMouseY,
// which walks the full unwindowed layout and only lands on-screen when everything
// fits in one viewport). It resolves against transcriptHitTestSource, the same
// subchat-aware source transcriptLineAtMouse uses, so it works for both the parent
// transcript and a subchat child session.
func topmostVisibleTranscriptMouseY(t *testing.T, m model) int {
t.Helper()
header, items, width := m.transcriptHitTestSource()
frame := m.scrollableTranscriptFrame(header, m.footerView(width))
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
window := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
layout := layoutVisibleTranscriptBodyItems(items, metrics, window)
for _, line := range layout.selectable {
if line.text != "" && !line.toggle {
return frame.bodyRect.y + (line.bodyY - window.start)
}
}
t.Fatalf("no selectable visible transcript text line found: %#v", layout.selectable)
return 0
}
+71 -14
View File
@@ -12,8 +12,7 @@ import (
// may be swapped at startup (background detection / ZERO_THEME / --theme) or live
// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays
// and renders plain text when there is no TTY (tests). Every renderer consumes
// these named styles — no hex literal may appear outside theme_palettes.go (the
// palette tables + theme registry).
// these named styles — no hex literal may appear outside this file.
type tuiTheme struct {
// Base tokens.
ink lipgloss.Style // primary text
@@ -30,7 +29,6 @@ type tuiTheme struct {
line lipgloss.Style // default borders, rules, status separators
lineStrong lipgloss.Style // emphasized borders
selection lipgloss.Style // transcript selection highlight
hover lipgloss.Style // hovered clickable row (specialist card, toggle, sidebar/plan row)
// Title bar.
badge lipgloss.Style // ` 0 ` brand chip: onAccent on accent, bold
@@ -93,10 +91,9 @@ type tuiTheme struct {
}
// palette is the raw color-token table for one theme. buildTheme turns it into a
// resolved tuiTheme. The palette literals and the ordered theme registry live in
// theme_palettes.go (the only place hex literals live). Dark palettes keep tints
// darker than ink so every pairing survives 256-color downsampling; light palettes
// are dark-on-light with the same intent inverted.
// resolved tuiTheme. darkPalette and lightPalette are the ONLY place hex literals
// live. All dark tints stay darker than ink so every pairing survives 256-color
// downsampling; the light set is dark-on-light with the same intent inverted.
type palette struct {
panel string // card backgrounds (the terminal canvas itself is never painted full-bleed)
promptBg string // submitted user prompt background
@@ -127,6 +124,73 @@ type palette struct {
cardPerm string // permission card border (amber mixed into line)
}
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f",
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// lightPalette is dark-on-light: light-gray surfaces with near-black ink. The
// muted/faint/faintest grays get progressively LIGHTER (toward the surface) so the
// same text hierarchy reads on white; the lime accent is darkened to keep contrast
// against a light background, and the diff/permission tints become light surfaces.
var lightPalette = palette{
panel: "#ececed", // card backgrounds (slightly off-white)
promptBg: "#dcdce0", // submitted prompt background
line: "#cfcfd5", // default borders
line2: "#b0b0b8", // emphasized borders
ink: "#1b1b1d", // primary text (near-black)
muted: "#4a4a52", // secondary text — darkened so it clearly out-ranks faint on light
faint: "#57575f", // hints, metadata — nudged down to separate from faintest
faintest: "#646469", // line numbers, separators — pinned at the WCAG-AA floor on the light panel
accent: "#477006", // brand lime, darkened to AA contrast on light
green: "#1c7a4a", // success / diff add
red: "#c0322c", // errors / diff del
amber: "#8f6200", // permission / warnings
blue: "#1d5fd6", // grep locations
gitAdd: "#2f7a3a", // footer PR diff additions
gitDel: "#a83a3a", // footer PR diff deletions
addBg: "#e2f3e6", // diff added-line bg (light green)
delBg: "#fbe6e6", // diff deleted-line bg (light red)
addBgWord: "#97d3ab", // changed span within an added line — deeper green (sep 1.49 vs addBg, addInk 4.7:1)
delBgWord: "#f1b3b3", // changed span within a deleted line — deeper red (sep 1.48 vs delBg, delInk 4.6:1)
permBg: "#fbf0d8", // permission card bg (light amber)
selBg: "#cfe78f", // selected row bg (light accent) — deepened from #e7f2cd so it separates from the near-white panel (sep 1.01→1.15); ink label stays >12:1, faint ~5:1
addInk: "#1d5b37", // added-line text
delInk: "#8e2d2d", // deleted-line text
onAccent: "#ffffff", // text on the (dark) accent/amber fills
cardRun: "#94ad60", // running card border
cardErr: "#cf9a9a", // errored card border
cardPerm: "#c9ad7d", // permission card border
}
// buildTheme resolves a palette into the styles every renderer uses.
func buildTheme(p palette) tuiTheme {
col := func(s string) color.Color { return lipgloss.Color(s) }
@@ -146,13 +210,6 @@ func buildTheme(p palette) tuiTheme {
line: fg(p.line),
lineStrong: fg(p.line2),
selection: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)),
// A full block (like selection) would be too heavy for something that
// repaints on every mouse movement; a soft foreground reads as
// "interactive" at a glance without competing with real content colors
// (e.g. a specialist card's red/green status glyph). Amber (not the brand
// lime accent) — lime read as too glaring for something that repaints
// continuously as the cursor moves.
hover: fg(p.amber),
badge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true),
-457
View File
@@ -1,457 +0,0 @@
package tui
import "strings"
// theme_palettes.go is the sole home of raw color hex in the TUI: every palette
// literal and the ordered theme registry live here, so theme.go stays hex-free
// (just the palette struct, buildTheme, and the resolved tuiTheme). Adding a theme
// is a new palette{...} literal plus one themeRegistry entry — nothing else.
//
// Contrast discipline: every palette must satisfy the same WCAG invariants the
// built-ins do (asserted across the whole registry in theme_select_test.go): ink
// and faintest ≥ AA on panel; onAccent ≥ AA on accent; addInk/delInk readable on
// their diff bands and word spans; the muted>faint>faintest ramp monotonic toward
// the surface; and selBg visibly separated from panel while its label stays legible.
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f", // original bright lime (the brand accent)
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
blue: "#7db4ff",
gitAdd: "#7db87a",
gitDel: "#b87a7a",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
permBg: "#1c1915",
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
addInk: "#bdeed7",
delInk: "#f2c4c4",
onAccent: "#000000",
cardRun: "#5a6b2e",
cardErr: "#6b3434",
cardPerm: "#6b5a2e",
}
// draculaPalette — the Dracula scheme (dracula.com): muted-violet surface, purple
// accent, high-chroma pink/green/cyan signals.
var draculaPalette = palette{
panel: "#282a36",
promptBg: "#383c4d",
line: "#363a4b",
line2: "#484c62",
ink: "#f8f8f2",
muted: "#b9bccb",
faint: "#a2a5b8",
faintest: "#9195ac",
accent: "#bd93f9",
green: "#50fa7b",
red: "#ff5555",
amber: "#ffb86c",
blue: "#8be9fd",
gitAdd: "#77c58c",
gitDel: "#d98d8d",
addBg: "#1c3b2a",
delBg: "#3a2026",
addBgWord: "#235035",
delBgWord: "#5e333b",
permBg: "#322a1e",
selBg: "#504482",
addInk: "#cbf2dd",
delInk: "#f4c9c9",
onAccent: "#000000",
cardRun: "#7c6aa6",
cardErr: "#98505a",
cardPerm: "#9a7c62",
}
// nordPalette — the Nord scheme (nordtheme.com): cool polar-night slate with a
// frost-blue accent and desaturated aurora signals.
var nordPalette = palette{
panel: "#3b4252",
promptBg: "#464f62",
line: "#434c5e",
line2: "#4c566a",
ink: "#eceff4",
muted: "#c8cfda",
faint: "#b4bdcb",
faintest: "#a5afc1",
accent: "#88c0d0",
green: "#a3be8c",
red: "#bf616a",
amber: "#d08770",
blue: "#81a1c1",
gitAdd: "#8ba077",
gitDel: "#b0757d",
addBg: "#37433a",
delBg: "#45383d",
addBgWord: "#456d46",
delBgWord: "#6d4650",
permBg: "#47413a",
selBg: "#40688a",
addInk: "#d6ecca",
delInk: "#f0c6cb",
onAccent: "#000000",
cardRun: "#4c6672",
cardErr: "#6b4a51",
cardPerm: "#6a564d",
}
// gruvboxPalette — Gruvbox dark (medium): warm retro browns with an olive-green
// accent and high-contrast cream ink.
var gruvboxPalette = palette{
panel: "#32302f",
promptBg: "#3c3836",
line: "#504945",
line2: "#665c54",
ink: "#ebdbb2",
muted: "#c9b99a",
faint: "#b7a78d",
faintest: "#a89984",
accent: "#8ec07c",
green: "#b8bb26",
red: "#fb4934",
amber: "#fabd2f",
blue: "#83a598",
gitAdd: "#98971a",
gitDel: "#cc241d",
addBg: "#2f3a29",
delBg: "#3b2b29",
addBgWord: "#3e5236",
delBgWord: "#593733",
permBg: "#38331e",
selBg: "#3d4e30",
addInk: "#c5e6b0",
delInk: "#f0c0bb",
onAccent: "#000000",
cardRun: "#6f8460",
cardErr: "#a5493d",
cardPerm: "#a5833a",
}
// tokyoNightPalette — Tokyo Night (storm): deep indigo surface, soft blue accent,
// cool high-contrast ink.
var tokyoNightPalette = palette{
panel: "#1e2030",
promptBg: "#2c3149",
line: "#262a3d",
line2: "#3b4261",
ink: "#c8d3f5",
muted: "#a9b1d0",
faint: "#9099b2",
faintest: "#838ba8",
accent: "#82aaff",
green: "#c3e88d",
red: "#ff757f",
amber: "#ffc777",
blue: "#86e1fc",
gitAdd: "#96bf7d",
gitDel: "#c77e85",
addBg: "#20303b",
delBg: "#37222c",
addBgWord: "#2b5a4a",
delBgWord: "#5c2e3a",
permBg: "#2a2419",
selBg: "#2a385b",
addInk: "#b8e4d3",
delInk: "#f3c4cb",
onAccent: "#000000",
cardRun: "#4b5d8b",
cardErr: "#7d4857",
cardPerm: "#7d6954",
}
// catppuccinPalette — Catppuccin Mocha: soft lavender surface with a mauve accent
// and pastel signals.
var catppuccinPalette = palette{
panel: "#1e1e2e",
promptBg: "#34364b",
line: "#313244",
line2: "#45475a",
ink: "#cdd6f4",
muted: "#a6adc8",
faint: "#9399b2",
faintest: "#83889f",
accent: "#cba6f7",
green: "#a6e3a1",
red: "#f38ba8",
amber: "#f9e2af",
blue: "#89b4fa",
gitAdd: "#8cbf8a",
gitDel: "#cc8a9b",
addBg: "#24312b",
delBg: "#3c2a32",
addBgWord: "#2f5140",
delBgWord: "#56333f",
permBg: "#29261b",
selBg: "#322e46",
addInk: "#c4ecd6",
delInk: "#f4cdd6",
onAccent: "#000000",
cardRun: "#7a6a99",
cardErr: "#8a5b72",
cardPerm: "#8d8274",
}
// oneDarkPalette — Atom One Dark: slate-gray surface with a blue accent.
var oneDarkPalette = palette{
panel: "#2e323b",
promptBg: "#3a3f4a",
line: "#393f4a",
line2: "#4b525f",
ink: "#abb2bf",
muted: "#a2a9b6",
faint: "#9aa1af",
faintest: "#969cab",
accent: "#61afef",
green: "#98c379",
red: "#e06c75",
amber: "#e5c07b",
blue: "#56b6c2",
gitAdd: "#82a06a",
gitDel: "#bd8087",
addBg: "#2c382b",
delBg: "#3a2d2f",
addBgWord: "#3d5a3a",
delBgWord: "#5c3e40",
permBg: "#3c3826",
selBg: "#354256",
addInk: "#cdeab3",
delInk: "#f0c3c7",
onAccent: "#000000",
cardRun: "#496c8c",
cardErr: "#7c515b",
cardPerm: "#7e735e",
}
// solarizedDarkPalette — Solarized Dark (Ethan Schoonover): the signature teal
// base03/base02 surface with a cyan accent and the fixed accent wheel.
var solarizedDarkPalette = palette{
panel: "#073642",
promptBg: "#0b3b46",
line: "#123f48",
line2: "#4b636c",
ink: "#cdd6d6",
muted: "#a9b3b3",
faint: "#9ba5a5",
faintest: "#929c9c",
accent: "#3bb3a6", // solarized cyan, brightened to clear AA on the lifted base02 panel
green: "#859900",
red: "#dc322f",
amber: "#b58900",
blue: "#268bd2",
gitAdd: "#93a05e",
gitDel: "#c67b71",
addBg: "#123f31",
delBg: "#45302e",
addBgWord: "#1e5c44",
delBgWord: "#6a3f3a",
permBg: "#2e2a18",
selBg: "#17505a",
addInk: "#c3ecd6",
delInk: "#f2cbc6",
onAccent: "#000000",
cardRun: "#1d6b6c",
cardErr: "#6d393d",
cardPerm: "#5b6028",
}
// rosePinePalette — Rosé Pine (main): muted rose-quartz base with a soft-rose
// accent and cool pine/foam signals.
var rosePinePalette = palette{
panel: "#1f1d2e",
promptBg: "#2f2b47",
line: "#2b2840",
line2: "#403d52",
ink: "#e0def4",
muted: "#a8a3c0",
faint: "#928ea9",
faintest: "#8985a0",
accent: "#ebbcba",
green: "#31748f",
red: "#eb6f92",
amber: "#f6c177",
blue: "#9ccfd8",
gitAdd: "#5e88a2",
gitDel: "#d589a7",
addBg: "#1f2d3a",
delBg: "#3a1f2d",
addBgWord: "#274b5e",
delBgWord: "#763a4f",
permBg: "#4a3e3d",
selBg: "#44415a",
addInk: "#cfe8ef",
delInk: "#f6c9cd",
onAccent: "#000000",
cardRun: "#7c6673",
cardErr: "#7c4662",
cardPerm: "#806857",
}
// everforestPalette — Everforest dark (medium): warm forest-gray surface with a
// sage-green accent.
var everforestPalette = palette{
panel: "#333c43",
promptBg: "#3d484d",
line: "#414b52",
line2: "#55636b",
ink: "#d3c6aa",
muted: "#b0bab0",
faint: "#a4aea3",
faintest: "#9ca99b",
accent: "#a7c080",
green: "#83c092",
red: "#e67e80",
amber: "#dbbc7f",
blue: "#7fbbb3",
gitAdd: "#8faa78",
gitDel: "#c08888",
addBg: "#2c3f37",
delBg: "#3e3234",
addBgWord: "#324e3b",
delBgWord: "#573a3c",
permBg: "#3c382d",
selBg: "#3b482e",
addInk: "#cfead0",
delInk: "#f4cfcd",
onAccent: "#000000",
cardRun: "#798b6b",
cardErr: "#9c676b",
cardPerm: "#96896b",
}
// lightPalette is dark-on-light: a warm cream surface (so cards lift off the
// terminal page, which Zero never paints) with near-black ink and an olive-lime
// accent that keeps the brand identity while clearing AA on the light panel. The
// muted/faint/faintest grays get progressively LIGHTER toward the surface; diff and
// permission tints are light surfaces. Replaces the old flat cool-gray light set
// whose muddy panel, sub-AA diff spans, and near-invisible selBg read as broken.
var lightPalette = palette{
panel: "#efebd4",
promptBg: "#e3ddc2",
line: "#d8d2bd",
line2: "#b7b199",
ink: "#22201a",
muted: "#4b5149",
faint: "#575e55",
faintest: "#636a61",
accent: "#54700a",
green: "#1e725c",
red: "#c02434",
amber: "#8a5f00",
blue: "#1f66c0",
gitAdd: "#3d6f46",
gitDel: "#a34a4a",
addBg: "#ddf0df",
delBg: "#f8dcdc",
addBgWord: "#a2daae",
delBgWord: "#f2b6b6",
permBg: "#f7ebc6",
selBg: "#d4e08f",
addInk: "#0c4026",
delInk: "#641a1d",
onAccent: "#ffffff",
cardRun: "#b0be7e",
cardErr: "#d8b0a8",
cardPerm: "#d6c496",
}
// solarizedLightPalette — Solarized Light: the base3/base2 cream surface with the
// same fixed accent wheel as Solarized Dark, dark-on-light.
var solarizedLightPalette = palette{
panel: "#eee8d5",
promptBg: "#e1d9be",
line: "#d8d1bc",
line2: "#c0b89e",
ink: "#304049",
muted: "#495b61",
faint: "#506469",
faintest: "#576b72",
accent: "#0c665c", // solarized cyan, darkened for AA on the cream panel (with white onAccent)
green: "#859900",
red: "#dc322f",
amber: "#7a5c00", // solarized yellow, darkened so white onAccent works on amber fills
blue: "#268bd2",
gitAdd: "#788d34",
gitDel: "#ac4f50",
addBg: "#dde8c6",
delBg: "#f1ddd2",
addBgWord: "#b9d488", // deepened so the changed span separates from addBg (sep 1.28)
delBgWord: "#edc4b4",
permBg: "#f0e6bd",
selBg: "#a6d6c4",
addInk: "#38480a",
delInk: "#6f1614",
onAccent: "#ffffff", // white — accent and amber are both dark on this light theme
cardRun: "#7fbaaf",
cardErr: "#d8837a",
cardPerm: "#c4ae63",
}
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
type themeEntry struct {
Name string
Label string
Palette palette
IsDark bool
}
// themeRegistry is the ordered source of truth for every selectable theme. Order is
// the picker order: all Dark themes first, then all Light, with the brand
// dark/light built-ins leading their groups. themeModes (theme_select.go) prepends
// `auto` to this. Append here to add a theme — nothing else needs editing.
var themeRegistry = []themeEntry{
{Name: "dark", Label: "dark", Palette: darkPalette, IsDark: true},
{Name: "dracula", Label: "Dracula", Palette: draculaPalette, IsDark: true},
{Name: "nord", Label: "Nord", Palette: nordPalette, IsDark: true},
{Name: "gruvbox", Label: "Gruvbox", Palette: gruvboxPalette, IsDark: true},
{Name: "tokyo-night", Label: "Tokyo Night", Palette: tokyoNightPalette, IsDark: true},
{Name: "catppuccin", Label: "Catppuccin", Palette: catppuccinPalette, IsDark: true},
{Name: "one-dark", Label: "One Dark", Palette: oneDarkPalette, IsDark: true},
{Name: "solarized-dark", Label: "Solarized Dark", Palette: solarizedDarkPalette, IsDark: true},
{Name: "rose-pine", Label: "Rosé Pine", Palette: rosePinePalette, IsDark: true},
{Name: "everforest", Label: "Everforest", Palette: everforestPalette, IsDark: true},
{Name: "light", Label: "light", Palette: lightPalette, IsDark: false},
{Name: "solarized-light", Label: "Solarized Light", Palette: solarizedLightPalette, IsDark: false},
}
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
// var initializer (not init()) so it is ready before themeModes' package-var
// initializer calls themeNames().
var themeByName = func() map[string]themeEntry {
byName := make(map[string]themeEntry, len(themeRegistry))
for _, entry := range themeRegistry {
byName[entry.Name] = entry
}
return byName
}()
// lookupTheme resolves a theme name (case/space-insensitive) to its entry.
func lookupTheme(name string) (themeEntry, bool) {
entry, ok := themeByName[strings.ToLower(strings.TrimSpace(name))]
return entry, ok
}
// themeNames returns every registered theme name in registry (picker) order.
func themeNames() []string {
names := make([]string, len(themeRegistry))
for index, entry := range themeRegistry {
names[index] = entry.Name
}
return names
}
-340
View File
@@ -1,340 +0,0 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// A committed theme is written to user config and reloaded at startup (via
// Options.SavedTheme -> resolveThemeMode), so a /theme choice survives restart.
func TestThemeChoicePersistsAcrossRestart(t *testing.T) {
defer applyTheme(themeDark, true)
cfgPath := filepath.Join(t.TempDir(), "config.json")
// First session: pick a color theme via the /theme command (same commit path the
// picker uses).
m := newModel(context.Background(), Options{UserConfigPath: cfgPath})
m, _ = m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
t.Fatalf("themeMode = %q, want dracula", m.themeMode)
}
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("theme commit should have written config: %v", err)
}
var cfg struct {
Preferences struct {
Theme string `json:"theme"`
} `json:"preferences"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("config is not valid JSON: %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("preferences.theme = %q, want dracula", cfg.Preferences.Theme)
}
// Second session: the persisted theme seeds startup (no flag / env).
restarted := newModel(context.Background(), Options{UserConfigPath: cfgPath, SavedTheme: "dracula"})
if restarted.themeMode != themeMode("dracula") {
t.Fatalf("restarted themeMode = %q, want dracula (from saved config)", restarted.themeMode)
}
}
// resolveThemeMode precedence: flag > ZERO_THEME > persisted config > auto.
func TestResolveThemeModeConfigFallback(t *testing.T) {
if got := resolveThemeMode("", "", "nord"); got != themeMode("nord") {
t.Errorf("saved-only = %q, want nord", got)
}
if got := resolveThemeMode("dracula", "", "nord"); got != themeMode("dracula") {
t.Errorf("flag should beat saved: got %q, want dracula", got)
}
if got := resolveThemeMode("", "gruvbox", "nord"); got != themeMode("gruvbox") {
t.Errorf("env should beat saved: got %q, want gruvbox", got)
}
if got := resolveThemeMode("", "", "bogus-theme"); got != themeAuto {
t.Errorf("unknown saved theme should fall back to auto, got %q", got)
}
}
// themePickerRowIndex finds the picker row whose Value is name (index-independent,
// so tests survive registry reordering / additions).
func themePickerRowIndex(t *testing.T, p *commandPicker, name string) int {
t.Helper()
for i, item := range p.items {
if item.Value == name {
return i
}
}
t.Fatalf("theme picker has no row for %q", name)
return -1
}
// assertPreviewMatchesSelection checks the live palette equals the palette of the
// currently highlighted theme row (the auto row has no palette of its own).
func assertPreviewMatchesSelection(t *testing.T, m model) {
t.Helper()
sel := m.picker.items[m.picker.selected]
entry, ok := lookupTheme(sel.Value)
if !ok {
return
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, entry.Palette.ink) {
t.Errorf("preview of %q did not apply its palette (ink mismatch)", sel.Value)
}
}
// Bare `/theme` opens the popup picker (like /model and /effort) with one row per
// theme mode, preselecting the active preference. An explicit `/theme <mode>` must
// keep taking the text path so scripts and the existing state view still work.
func TestThemePickerOpensOnBareTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeLight
m.input.SetValue("/theme")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("opening the theme picker should not emit a cmd, got %T", cmd)
}
if m.picker == nil || m.picker.kind != pickerTheme {
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
}
if len(m.picker.items) != len(themeModes) {
t.Fatalf("picker has %d items, want %d", len(m.picker.items), len(themeModes))
}
for i, want := range themeModes {
if m.picker.items[i].Value != want {
t.Errorf("item %d value = %q, want %q", i, m.picker.items[i].Value, want)
}
}
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Errorf("preselected value = %q, want the active mode %q", got, themeLight)
}
}
// An explicit mode argument runs the text handler directly and never opens the
// popup — guards the dispatch branch and keeps /theme <mode> scriptable.
func TestThemeArgSkipsPicker(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme dark")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatalf("explicit /theme dark must not open a picker, got %#v", m.picker)
}
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
}
}
// Moving the cursor live-previews each palette: the global zeroTheme swaps as the
// selection changes, so the whole UI (and the overlay) repaints in the hovered
// theme without committing the preference.
func TestThemePickerPreviewsOnMove(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Each Down moves to the next theme and live-applies its palette; Up follows back.
for i := 0; i < 3; i++ {
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
}
updated, _ = m.Update(testKey(tea.KeyUp))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
// Preview must not have written the committed preference.
if m.themeMode != themeDark {
t.Errorf("preview mutated the committed mode to %q", m.themeMode)
}
}
// Enter on a fixed mode commits it: the palette stays applied, the preference is
// recorded, the popup closes, and the switch is noted in the transcript.
func TestThemePickerCommitAppliesAndRecords(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Choose a color theme by locating its row (index-independent), then confirm.
m.picker.selected = themePickerRowIndex(t, m.picker, "dracula")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("committing a fixed theme should not emit a cmd, got %T", cmd)
}
if m.picker != nil {
t.Fatal("committing should close the picker")
}
if m.themeMode != themeMode("dracula") {
t.Fatalf("committed mode = %q, want dracula", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, draculaPalette.ink) {
t.Error("commit did not leave the dracula palette applied")
}
if !transcriptContains(m.transcript, "active theme") {
t.Errorf("commit should record the switch in the transcript:\n%s", transcriptText(m.transcript))
}
}
// Committing `auto` re-probes the terminal background (like the text dispatch) so
// the palette re-detects light/dark instead of reusing the preview's reading.
func TestThemePickerAutoCommitReprobesBackground(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
m.picker.selected = 0 // "auto"
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.themeMode != themeAuto {
t.Fatalf("committed mode = %q, want auto", m.themeMode)
}
if cmd == nil {
t.Fatal("committing auto must return a background re-probe cmd")
}
if reflect.ValueOf(cmd).Pointer() != reflect.ValueOf(tea.RequestBackgroundColor).Pointer() {
t.Error("committing auto must return tea.RequestBackgroundColor")
}
}
// Typing to filter the list re-previews the newly-highlighted mode, so the applied
// palette never diverges from the row the popup points at. Backspacing back to an
// empty query re-previews the (restored) selection too.
func TestThemePickerFilterRePreviews(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview light by moving, then filter to "light" — the applied palette must
// track the filtered highlight (still light here), not lag behind.
updated, _ = m.Update(testKey(tea.KeyDown)) // dark -> light preview
m = updated.(model)
updated, _ = m.Update(testKeyText("l"))
m = updated.(model)
updated, _ = m.Update(testKeyText("i"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Fatalf("filter 'li' should highlight light, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("filtering to light did not keep the light palette previewed")
}
// Re-filter to "dark": highlight and preview must both switch to dark.
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKeyText("d"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeDark) {
t.Fatalf("filter 'd' should highlight dark, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("filtering to dark did not re-preview the dark palette")
}
}
// A filter that matches nothing must not strand the last preview: the palette
// falls back to the committed theme, and committing (Enter on the empty list) is a
// clean no-op that leaves the committed mode and palette in agreement.
func TestThemePickerEmptyFilterRestoresCommitted(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then type a char that matches no theme name
// ('q' appears in none) -> the list empties.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
updated, _ = m.Update(testKeyText("q"))
m = updated.(model)
if len(m.picker.items) != 0 {
t.Fatalf("filter 'q' should match no theme, got %d items", len(m.picker.items))
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("an empty filter should restore the committed dark palette, not keep the preview")
}
// Enter on the empty list closes the picker without changing the committed
// preference, and leaves the palette matching it.
updated, _ = m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatal("Enter on an empty filter should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("empty-list commit must not change the mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("after an empty-list commit the palette must match the committed dark mode")
}
}
// Esc dismisses the picker without choosing and restores the committed palette,
// undoing whatever the live preview applied.
func TestThemePickerEscRestoresCommittedTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then bail out.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r == mustR(t, darkPalette.ink) {
t.Fatal("expected a non-dark preview to be applied before Esc")
}
updated, _ = m.Update(testKey(tea.KeyEsc))
m = updated.(model)
if m.picker != nil {
t.Fatal("Esc should close the picker")
}
if m.themeMode != themeDark {
t.Fatalf("Esc must not change the committed mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("Esc did not restore the committed dark palette")
}
}
+28 -98
View File
@@ -1,10 +1,6 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/config"
)
import "strings"
// themeMode is the operator's palette preference.
type themeMode string
@@ -15,49 +11,34 @@ const (
themeLight themeMode = "light"
)
// themeModes lists the values /theme accepts, in picker order: `auto` first, then
// every registered theme (theme_palettes.go). It is the single ordered source
// feeding both the picker and the /theme state list — adding a registry entry
// extends it automatically.
var themeModes = append([]string{string(themeAuto)}, themeNames()...)
// themeModes lists the values /theme accepts.
var themeModes = []string{string(themeAuto), string(themeDark), string(themeLight)}
// resolveThemeMode picks the first accepted preference from candidates in
// precedence order — the caller passes them highest-first: the --theme flag, then
// ZERO_THEME, then the persisted config theme. A value is accepted if it is `auto`
// or names a registered theme; unrecognized/blank values are skipped, and an empty
// list (or all-unrecognized) falls back to auto.
func resolveThemeMode(candidates ...string) themeMode {
for _, v := range candidates {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
continue
}
if s == string(themeAuto) {
// resolveThemeMode picks the preference in precedence order: an explicit value
// (the --theme flag, threaded via Options.Theme), then ZERO_THEME, then auto.
func resolveThemeMode(flagValue, envValue string) themeMode {
for _, v := range []string{flagValue, envValue} {
switch strings.ToLower(strings.TrimSpace(v)) {
case "dark":
return themeDark
case "light":
return themeLight
case "auto":
return themeAuto
}
if _, ok := lookupTheme(s); ok {
return themeMode(s)
}
}
return themeAuto
}
// validThemeMode reports whether s names a theme mode (for /theme validation):
// `auto` or any registered theme.
// validThemeMode reports whether s names a theme mode (for /theme validation).
func validThemeMode(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
if s == string(themeAuto) {
switch themeMode(strings.ToLower(strings.TrimSpace(s))) {
case themeAuto, themeDark, themeLight:
return true
}
_, ok := lookupTheme(s)
return ok
return false
}
// ValidThemeArg reports whether s is an acceptable --theme / ZERO_THEME value
// (`auto` or a registered theme name). Exported so the CLI flag validator shares
// this one source of truth instead of hardcoding the theme list.
func ValidThemeArg(s string) bool { return validThemeMode(s) }
// applyTheme swaps the active palette (zeroTheme) and the globals derived from it
// — the streaming-fade ramp and the static render cache — so a switch repaints
// every subsequent render. For themeAuto it resolves to dark/light from
@@ -72,13 +53,12 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
resolved = themeLight
}
}
// Resolve the (now concrete) mode to its registered palette; an unknown name
// falls back to the dark built-in so a bad value can never leave zeroTheme unset.
entry, ok := lookupTheme(string(resolved))
if !ok {
entry, _ = lookupTheme(string(themeDark))
switch resolved {
case themeLight:
zeroTheme = buildTheme(lightPalette)
default:
zeroTheme = buildTheme(darkPalette)
}
zeroTheme = buildTheme(entry.Palette)
rebuildStreamingFadePalette()
if defaultRenderCache != nil {
defaultRenderCache.clear() // old-palette entries must not be reused
@@ -86,45 +66,15 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
return resolved
}
// previewSelectedTheme makes the live palette match the theme picker's current
// state: the highlighted mode when a row is selectable, or the committed
// m.themeMode when the filter matches nothing. Called on every change to the
// picker's selection or filter (arrow/wheel moves, mouse, and query typing) so the
// whole UI — and the overlay itself — always renders the mode the popup points at,
// and never strands on a stale preview. It only swaps the global zeroTheme via
// applyTheme; m.themeMode keeps the committed preference so Esc can restore it. A
// no-op unless a theme picker is open. Runs on the Update goroutine, like every
// zeroTheme access.
func (m model) previewSelectedTheme() {
if m.picker == nil || m.picker.kind != pickerTheme {
return
}
if item, ok := m.picker.current(); ok {
applyTheme(themeMode(item.Value), m.hasDarkBg)
return
}
// No selectable row (the filter matched nothing): fall back to the committed
// theme rather than leaving the previous preview applied.
m.restoreCommittedTheme()
}
// restoreCommittedTheme re-applies m.themeMode after a /theme preview is dismissed
// without choosing (Esc). Preview never wrote m.themeMode, so it still holds the
// real preference; re-applying repaints back to it.
func (m model) restoreCommittedTheme() {
applyTheme(m.themeMode, m.hasDarkBg)
}
// handleThemeCommand implements /theme [name]: `list` shows state, a registered
// theme name (or `auto`) switches the active palette live. Bare `/theme` opens the
// picker at the dispatch layer, so it never reaches here empty. Mirrors handleStyleCommand.
// handleThemeCommand implements /theme [auto|dark|light]: no arg shows state, a
// valid mode switches the active palette live. Mirrors handleStyleCommand.
func (m model) handleThemeCommand(args string) (model, string) {
arg := strings.ToLower(strings.TrimSpace(args))
if arg == "" || arg == "list" {
return m, m.themeStateText()
}
if !validThemeMode(arg) {
return m, "Theme\nUnknown theme: " + arg + " (use /theme with no argument to pick from the list)"
return m, "Theme\nUnknown theme: " + arg + " (use auto, dark, or light)"
}
m.themeMode = themeMode(arg)
resolved := applyTheme(m.themeMode, m.hasDarkBg)
@@ -132,31 +82,11 @@ func (m model) handleThemeCommand(args string) (model, string) {
if m.themeMode == themeAuto {
active = "auto (" + string(resolved) + ")"
}
lines := []string{
return m, strings.Join([]string{
"Theme",
"active theme: " + active,
"Already-printed scrollback keeps its previous colors; new output uses the new theme.",
}
// Commit path (both /theme <name> and the picker route through here): persist the
// choice so it survives restart. Previews call applyTheme directly and never reach here.
if note := m.persistThemePreference(); note != "" {
lines = append(lines, note)
}
return m, strings.Join(lines, "\n")
}
// persistThemePreference writes the committed theme to user config so it is applied
// again at startup (via Options.SavedTheme -> resolveThemeMode). Best-effort: returns
// a short note to surface on failure, or "" on success / when there is no config
// path (e.g. tests). Never called from the live-preview path.
func (m model) persistThemePreference() string {
if strings.TrimSpace(m.userConfigPath) == "" {
return ""
}
if _, err := config.SetTheme(m.userConfigPath, string(m.themeMode)); err != nil {
return "note: could not save theme preference (" + err.Error() + ")"
}
return ""
}, "\n")
}
// themeStateText renders the /theme state view.
@@ -179,6 +109,6 @@ func (m model) themeStateText() string {
"available: " + strings.Join(themeModes, ", "),
},
}},
Hints: []string{"run /theme with no argument to open the picker, or /theme <name> to switch directly"},
Hints: []string{"use /theme <auto|dark|light> to switch this TUI session"},
})
}
+26 -55
View File
@@ -52,19 +52,24 @@ func wcagRatio(t *testing.T, fg, bg string) float64 {
// The word-level diff's brighter changed-span band must keep its text AA-readable
// and stay clearly distinct from the base add/del band, on both themes.
func TestDiffWordSpanContrast(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.addInk, c.pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", c.name, r)
}
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
if r := wcagRatio(t, c.pal.delInk, c.pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", c.name, r)
}
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
if sep := wcagRatio(t, c.pal.addBgWord, c.pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
}
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
if sep := wcagRatio(t, c.pal.delBgWord, c.pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", c.name, sep)
}
}
}
@@ -73,52 +78,18 @@ func TestDiffWordSpanContrast(t *testing.T) {
// AND keep its label readable. Guards the regression this fixes: the light
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
for _, c := range []struct {
name string
pal palette
}{
{"dark", darkPalette},
{"light", lightPalette},
} {
if r := wcagRatio(t, c.pal.ink, c.pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", c.name, r)
}
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
}
}
}
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
// for light themes). Guards that a newly-added color palette can't ship illegible.
func TestAllThemesContrastAndHierarchy(t *testing.T) {
for _, entry := range themeRegistry {
pal := entry.Palette
for _, tok := range []struct {
name string
fg string
}{
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
{"faintest", pal.faintest}, {"accent", pal.accent},
} {
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
}
}
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
}
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
// rises toward the surface on light themes, falls on dark themes.
chain := []float64{
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
relLum(t, pal.faintest), relLum(t, pal.panel),
}
for i := 1; i < len(chain); i++ {
ok := chain[i] > chain[i-1]
if entry.IsDark {
ok = chain[i] < chain[i-1]
}
if !ok {
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
}
if sep := wcagRatio(t, c.pal.selBg, c.pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", c.name, sep)
}
}
}
+16 -278
View File
@@ -22,20 +22,8 @@ type transcriptSelectionPoint struct {
type transcriptSelectionState struct {
active bool
// dragging is true from press until release — narrower than active, which
// stays true through the async copy-command grace window after release (a
// non-empty selection isn't reset to {} until transcriptCopiedMsg lands).
// mouseMotion gates on THIS, not active, so a genuine hover motion arriving
// in that post-release window doesn't get misrouted as a drag continuation.
// It also deliberately does NOT require msg's own Button field to be
// non-None on each motion event: some terminals don't restate the button on
// every motion report during a real drag (see
// TestTranscriptSelectionUpdatesOnGenericMotion), so trusting the
// per-event button field would break those terminals — dragging tracks the
// app's own press/release bracket instead.
dragging bool
anchor transcriptSelectionPoint
cursor transcriptSelectionPoint
anchor transcriptSelectionPoint
cursor transcriptSelectionPoint
}
type transcriptSelectableLine struct {
@@ -171,45 +159,10 @@ func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcrip
shifted := shiftSelectableX(selectable, gutter)
if m.transcriptSelection.active {
lines = viewLines(m.renderRenderedSelection(strings.Join(lines, "\n"), shifted, startBodyY))
} else if m.hover.kind == hoverTranscript {
// Mutually exclusive with the selection highlight above: transcriptSelection
// stays active for the lifetime of a persisted (copied) selection, not just
// during the drag — so hover correctly stays suppressed over old selected
// text too, not only mid-drag.
lines = viewLines(m.renderHoverHighlight(strings.Join(lines, "\n"), shifted, startBodyY))
}
return transcriptBodyRenderedItem{lines: lines, selectable: shifted}
}
// renderHoverHighlight re-styles the WHOLE line at m.hover.bodyY in the hover
// accent (see renderRenderedSelection for the sibling selection-highlight logic,
// which this mirrors). Only a clickable line (a specialist card or a
// collapse/expand toggle header) is eligible — if the hovered bodyY no longer
// matches one (content shifted beneath a stale hover target), this is a no-op,
// which self-heals a hover left over from before a transcript update.
func (m model) renderHoverHighlight(rendered string, selectable []transcriptSelectableLine, startBodyY int) string {
index := m.hover.bodyY - startBodyY
if index < 0 {
return rendered
}
lines := viewLines(rendered)
if index >= len(lines) {
return rendered
}
matched := false
for _, line := range selectable {
if line.bodyY == m.hover.bodyY && (line.specialistCard || line.toggle) {
matched = true
break
}
}
if !matched {
return rendered
}
lines[index] = zeroTheme.hover.Render(ansi.Strip(lines[index]))
return strings.Join(lines, "\n")
}
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
items := []transcriptBodyItem{}
// Transcript ROWS render at the full chat width; row/status glyphs provide
@@ -1015,46 +968,16 @@ func splitPlainAtDisplayWidth(text string, width int) (string, string) {
return text, ""
}
// transcriptHitTestSource returns the header/body-items/width mouse hit-testing
// must use to match what's actually on screen. It mirrors transcriptView's own
// branching exactly: the subchat drill-in swaps BOTH the header (nav bar instead
// of the pinned title bar) and the row source (the child session's rows instead
// of the parent transcript) — and drops the sidebar-reserved width, since subchat
// is always single-column. Hit-testing against the wrong source is why mouse
// selection previously resolved against transcript rows that weren't even
// visible while viewing a subagent/swarm child session.
func (m model) transcriptHitTestSource() (header string, items []transcriptBodyItem, width int) {
if m.subchat.active {
width = chatWidth(m.width)
return renderSubchatNavBar(m.subchat.childSessionTitle, width), m.transcriptBodyItemsFromRows(m.subchat.childRows, width), width
}
width = m.chatColumnWidth()
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, ""), width
}
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
func (m model) transcriptHitTestBlocked() bool {
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
}
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,
// shared by transcriptLineAtMouse (exact match) and nearestTranscriptLineAtMouse
// (nearest-line fallback for scroll-driven selection extension).
func (m model) transcriptHitTestLayout() (frame transcriptFrameLayout, window transcriptViewportWindow, layout transcriptBodyLayout) {
header, items, width := m.transcriptHitTestSource()
frame = m.scrollableTranscriptFrame(header, m.footerView(width))
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
layout = layoutVisibleTranscriptBodyItems(items, metrics, window)
return frame, window, layout
}
func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine, bool) {
if m.transcriptHitTestBlocked() {
if !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return transcriptSelectableLine{}, false
}
frame, window, layout := m.transcriptHitTestLayout()
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
items := m.transcriptBodyItems(width, "")
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
window := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
layout := layoutVisibleTranscriptBodyItems(items, metrics, window)
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
if !ok {
return transcriptSelectableLine{}, false
@@ -1072,146 +995,6 @@ func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine
return transcriptSelectableLine{}, false
}
// nearestTranscriptLineAtMouse is transcriptLineAtMouse with a fallback for
// scroll-driven selection extension: the recomputed on-screen position can land
// exactly on a non-selectable spacer row between messages (blank lines aren't in
// layout.selectable; a scroll can shift which physical row is text vs spacer), where
// an EXACT match finds nothing even though real text sits one row away. Falls back
// to the closest visible selectable line by bodyY, so the selection still extends
// instead of silently freezing for that scroll tick. Only used for scroll
// extension — a plain click keeps using transcriptLineAtMouse's exact match, since
// "nothing there" is a meaningful, correct result for an intentional click below
// the last line or in dead space.
func (m model) nearestTranscriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine, bool) {
if line, ok := m.transcriptLineAtMouse(msg); ok {
return line, true
}
if m.transcriptHitTestBlocked() {
return transcriptSelectableLine{}, false
}
frame, window, layout := m.transcriptHitTestLayout()
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
if !ok || mouseX(msg) < 0 {
return transcriptSelectableLine{}, false
}
return nearestTranscriptSelectableAt(layout, window.start+localY)
}
// nearestTranscriptSelectableAt returns the selectable line in layout closest to
// targetBodyY (ties go to whichever is found first) — the fallback core shared by
// nearestTranscriptLineAtMouse (target derived from a mouse position that may
// have shifted onto a non-selectable spacer row) and the edge-auto-scroll drag
// case (target derived directly from the new viewport's top/bottom bound, which
// has no "mouse position" to speak of at all).
func nearestTranscriptSelectableAt(layout transcriptBodyLayout, targetBodyY int) (transcriptSelectableLine, bool) {
best := transcriptSelectableLine{}
bestDist := -1
found := false
for _, line := range layout.selectable {
dist := line.bodyY - targetBodyY
if dist < 0 {
dist = -dist
}
if !found || dist < bestDist {
best, bestDist, found = line, dist, true
}
}
return best, found
}
// transcriptEdgeScrollDelta reports the scroll delta to apply when a drag has
// moved past the top or bottom edge of the visible transcript body while
// staying within its horizontal span — the classic "drag past the viewport
// edge auto-scrolls" affordance from browsers/editors. Positive reveals OLDER
// content (dragged above the top edge, same sign convention as wheel-up);
// negative reveals NEWER content (dragged below the bottom edge). ok is false
// when the point isn't a vertical-edge case at all (e.g. off to the side, over
// the sidebar) — that's not an edge-scroll, just outside the column.
func (m model) transcriptEdgeScrollDelta(msg tea.MouseMsg) (int, bool) {
if m.transcriptHitTestBlocked() {
return 0, false
}
frame, _, _ := m.transcriptHitTestLayout()
x, y := mouseX(msg), mouseY(msg)
if x < frame.bodyRect.x || x >= frame.bodyRect.x+frame.bodyRect.width {
return 0, false
}
if y < frame.bodyRect.y {
return chatWheelScrollLines, true
}
if y >= frame.bodyRect.y+frame.bodyRect.height {
return -chatWheelScrollLines, true
}
return 0, false
}
// dragToEdgeScroll scrolls one step toward the edge the drag moved past, then
// extends the selection cursor to whichever line now sits at THAT edge of the
// new viewport, at column x. It deliberately does NOT try to re-resolve the
// drag's (still off-screen) physical mouse position — after scrolling, that
// position is still outside frame.bodyRect (scrolling changes what content
// occupies a screen row, not the mouse's screen position), so
// nearestTranscriptLineAtMouse would keep finding nothing. Anchoring to the
// viewport edge instead is what makes the selection visibly keep pace with the
// drag while the mouse holds past the edge. Takes a plain column rather than a
// tea.MouseMsg because the smooth-glide tick chain (dragEdgeScrollTickMsg) calls
// this repeatedly with no real mouse event of its own — see edgeScrollMouseX.
func (m model) dragToEdgeScroll(delta int, x int) model {
m = m.scrollChat(delta)
_, window, layout := m.transcriptHitTestLayout()
target := window.start
if delta < 0 {
target = window.start + window.height - 1
}
if line, ok := nearestTranscriptSelectableAt(layout, target); ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, x)
}
return m
}
// dragEdgeScrollTickCmd schedules the next step of the smooth-glide edge-scroll,
// gated by seq (see dragEdgeScrollTickMsg / edgeScrollSeq).
func dragEdgeScrollTickCmd(seq int) tea.Cmd {
return tea.Tick(dragEdgeScrollInterval, func(time.Time) tea.Msg {
return dragEdgeScrollTickMsg{seq: seq}
})
}
// startEdgeScroll (re)starts the smooth-glide tick chain in the given direction —
// a no-op if it's already running the SAME direction (a fresh raw motion event
// arriving mid-glide must not reset the cadence, or repeated events faster than
// dragEdgeScrollInterval would make it jerky again, defeating the point). x is
// remembered for the ticks, which carry no mouse position of their own. Applies
// one immediate step so there's no perceptible delay between crossing the edge
// and the first visible movement.
func (m model) startEdgeScroll(direction int, x int) (model, tea.Cmd) {
step := dragEdgeScrollStep
if direction < 0 {
step = -step
}
if m.edgeScrollDelta == step {
m.edgeScrollMouseX = x
return m, nil
}
m = m.dragToEdgeScroll(step, x)
m.edgeScrollDelta = step
m.edgeScrollMouseX = x
m.edgeScrollSeq++
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
}
// stopEdgeScroll ends the smooth-glide tick chain (the drag moved back into the
// body, off to the side, or released). Bumping edgeScrollSeq invalidates any
// tick already in flight, so it lands as a no-op and doesn't reschedule itself.
func (m model) stopEdgeScroll() model {
if m.edgeScrollDelta == 0 {
return m
}
m.edgeScrollDelta = 0
m.edgeScrollSeq++
return m
}
func (m model) transcriptViewportStart(body string, width int) (int, int, int) {
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return transcriptViewportStartForFrame(body, frame, m.chatScrollOffset)
@@ -1246,7 +1029,6 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
m = m.appendSystemNotice(errMsg)
}
m.chatScrollOffset = 0
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil, true
}
// A click on a PLAN step row drops a transcript card listing the file
@@ -1276,7 +1058,6 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
m = m.appendSystemNotice(errMsg)
}
m.chatScrollOffset = 0
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil, true
}
if line.toggle {
@@ -1289,57 +1070,21 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
}
point := transcriptSelectionPointForMouse(line, mouseX(msg))
m.copyStatus = ""
m.transcriptSelection = transcriptSelectionState{active: true, dragging: true, anchor: point, cursor: point}
// A fresh press always starts a brand-new drag: reset any glide state left
// over from a PREVIOUS selection that ended without going through
// stopEdgeScroll (e.g. a keypress mid-drag clears transcriptSelection.active
// directly, model.go's KeyPressMsg handler). Without this, a stale
// edgeScrollDelta can coincidentally match this new drag's direction and
// fool startEdgeScroll's "already running" fast path into silently doing
// nothing for the whole hold.
m = m.stopEdgeScroll()
m.transcriptSelection = transcriptSelectionState{active: true, anchor: point, cursor: point}
return m, nil, true
case mouseMotion(msg):
// Gate on dragging (the app's own press/release bracket), not active
// alone: active stays true through the async copy-command grace window
// after release, so without this a genuine hover motion arriving in
// that window would be misrouted here and silently move the
// just-released selection instead of falling through to hover
// handling. Deliberately NOT gated on msg's own Button field — see
// transcriptSelectionState.dragging's doc comment.
if !m.transcriptSelection.dragging {
if !m.transcriptSelection.active {
return m, nil, false
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
line, ok := m.transcriptLineAtMouse(msg)
if ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
m = m.stopEdgeScroll() // back inside the body: any running glide ends
return m, nil, true
}
// The drag moved past the top/bottom edge of the visible transcript:
// auto-scroll toward that side and extend the selection to follow.
delta, ok := m.transcriptEdgeScrollDelta(msg)
if !ok {
m = m.stopEdgeScroll() // off to the side (e.g. over the sidebar), not an edge case
return m, nil, true
}
if m.reducedMotion {
// No animated glide: a single, larger step per raw motion event,
// matching the app's reduced-motion convention elsewhere.
m = m.dragToEdgeScroll(delta, mouseX(msg))
return m, nil, true
}
direction := 1
if delta < 0 {
direction = -1
}
var cmd tea.Cmd
m, cmd = m.startEdgeScroll(direction, mouseX(msg))
return m, cmd, true
return m, nil, true
case mouseRelease(msg):
if !m.transcriptSelection.active {
return m, nil, false
}
m = m.stopEdgeScroll()
if line, ok := m.transcriptLineAtMouse(msg); ok {
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
}
@@ -1348,10 +1093,6 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
m.transcriptSelection = transcriptSelectionState{}
return m, nil, true
}
// The drag itself is over even though the selection stays active for
// the async copy-command grace window below — see dragging's doc
// comment on why mouseMotion must stop treating events as a drag now.
m.transcriptSelection.dragging = false
return m, copyTranscriptSelectionCmd(text), true
default:
return m, nil, false
@@ -1372,11 +1113,8 @@ func (m model) toggleTranscriptRow(rowIndex int) model {
}
func (m model) selectedTranscriptText() string {
// Must read from the SAME row source transcriptLineAtMouse hit-tested against
// (subchat vs parent transcript) — otherwise the selection's bodyY range is
// matched against the wrong layout here and copy silently returns nothing.
_, items, _ := m.transcriptHitTestSource()
layout := layoutTranscriptBodyItems(items)
width := m.chatColumnWidth()
layout := m.transcriptBodyLayout(width, "")
parts := []string{}
for _, line := range layout.selectable {
start, end, ok := m.selectedColumnsForTranscriptLine(line)
-5
View File
@@ -5,11 +5,6 @@ import "strings"
func (m model) toggleDetailedTranscript() model {
m.transcriptDetailed = !m.transcriptDetailed
m.clearSuggestions()
if m.picker != nil && m.picker.kind == pickerTheme {
// Dropping a theme picker mid-preview must undo the live palette, or the
// previewed theme would stick while m.themeMode still holds the old one.
m.restoreCommittedTheme()
}
m.picker = nil
return m
}
-62
View File
@@ -1,62 +0,0 @@
package tui
import (
"os"
"strconv"
"strings"
"testing"
)
func TestHandleTurnsCommand(t *testing.T) {
// Isolate the inherited-by-children budget env this command exports.
t.Setenv("ZERO_MAX_TURNS", "")
t.Run("sets a valid budget and exports it for children", func(t *testing.T) {
got, out := model{}.handleTurnsCommand("150")
if got.agentOptions.MaxTurns != 150 {
t.Fatalf("MaxTurns = %d, want 150", got.agentOptions.MaxTurns)
}
if !strings.Contains(out, "150") {
t.Fatalf("output should report 150, got %q", out)
}
if env := os.Getenv("ZERO_MAX_TURNS"); env != "150" {
t.Fatalf("ZERO_MAX_TURNS = %q, want 150 (so sub-agents inherit it)", env)
}
})
t.Run("clamps above the ceiling (env too)", func(t *testing.T) {
got, _ := model{}.handleTurnsCommand("99999")
if got.agentOptions.MaxTurns != maxTurnsCeiling {
t.Fatalf("MaxTurns = %d, want clamp to %d", got.agentOptions.MaxTurns, maxTurnsCeiling)
}
if env := os.Getenv("ZERO_MAX_TURNS"); env != strconv.Itoa(maxTurnsCeiling) {
t.Fatalf("ZERO_MAX_TURNS = %q, want clamped %d", env, maxTurnsCeiling)
}
})
t.Run("invalid / non-positive input is rejected without changing the budget", func(t *testing.T) {
for _, in := range []string{"abc", "0", "-5", "3.5"} {
m := model{}
m.agentOptions.MaxTurns = 42
got, out := m.handleTurnsCommand(in)
if got.agentOptions.MaxTurns != 42 {
t.Fatalf("input %q changed MaxTurns to %d, want unchanged (42)", in, got.agentOptions.MaxTurns)
}
if !strings.Contains(out, "Usage") {
t.Fatalf("input %q should show usage, got %q", in, out)
}
}
})
t.Run("empty / status shows the current budget without changing it", func(t *testing.T) {
m := model{}
m.agentOptions.MaxTurns = 42
got, out := m.handleTurnsCommand("")
if got.agentOptions.MaxTurns != 42 {
t.Fatalf("status changed MaxTurns to %d, want 42", got.agentOptions.MaxTurns)
}
if !strings.Contains(out, "42") {
t.Fatalf("status should report current 42, got %q", out)
}
})
}