mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40da4ee88 | ||
|
|
c0254e3266 | ||
|
|
c4c14d62d4 | ||
|
|
49bec0274f | ||
|
|
bcc65da2a0 | ||
|
|
2a8802e39c | ||
|
|
d53052071a | ||
|
|
1c65f59be6 | ||
|
|
7fb0092de7 | ||
|
|
5aced721b3 | ||
|
|
02a3d12f34 | ||
|
|
a0548d707b | ||
|
|
83a371d669 | ||
|
|
84823d7be1 |
No files matched your search
+8
-1
@@ -11,7 +11,14 @@ 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.
|
||||
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
|
||||
- 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).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
|
||||
@@ -254,9 +254,9 @@ zero update check for newer releases
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `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_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_NO_FADE=1` | disables streaming fade animation |
|
||||
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
|
||||
@@ -280,6 +280,25 @@ 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.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
+151
-36
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,6 +21,12 @@ 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"
|
||||
@@ -203,55 +210,72 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
// 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.
|
||||
// REACTIVE compaction: the streamed error may also be a context limit
|
||||
// (some providers surface it mid-stream). Compact and retry once.
|
||||
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
|
||||
messages = compacted
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryErr
|
||||
return collected, 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 they stay valid after compaction.
|
||||
// Routing through an empty-loaded partition here would re-hide every
|
||||
// already-loaded deferred tool.
|
||||
// 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.
|
||||
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 {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryStreamErr
|
||||
return collected, 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
|
||||
@@ -260,9 +284,45 @@ 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 != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(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)
|
||||
}
|
||||
}
|
||||
|
||||
// Carry the turn's terminal stop reason so a final answer cut off at the
|
||||
@@ -615,8 +675,20 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
|
||||
for i, call := range calls {
|
||||
safe[i] = call
|
||||
args := strings.TrimSpace(call.Arguments)
|
||||
if args == "" || !json.Valid([]byte(args)) {
|
||||
switch {
|
||||
case 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
|
||||
@@ -641,10 +713,53 @@ 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 := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
|
||||
@@ -1053,7 +1053,10 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
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"}`},
|
||||
// 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.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -1103,6 +1106,53 @@ 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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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
@@ -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 {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 <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, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -641,6 +641,7 @@ 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,
|
||||
@@ -1033,9 +1034,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
@@ -1043,7 +1045,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, dark, or light)")
|
||||
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
|
||||
}
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = args[2:]
|
||||
@@ -1053,12 +1055,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
default:
|
||||
return theme, args, nil
|
||||
}
|
||||
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)
|
||||
if !tui.ValidThemeArg(value) {
|
||||
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
|
||||
}
|
||||
theme = strings.ToLower(value)
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
|
||||
@@ -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, dark, or light") {
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,9 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
|
||||
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
|
||||
AllowPresets: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenRes
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// Refreshing a token the user logged into (possibly a preset provider like
|
||||
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -13,7 +14,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
const defaultMaxTurns = 30
|
||||
// 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
|
||||
|
||||
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
|
||||
// Zero collapses their full JSON schemas into compact `tool_search` reminder
|
||||
@@ -186,6 +195,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -431,11 +443,53 @@ 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, "ZERO_PROVIDER"))
|
||||
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
|
||||
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),
|
||||
|
||||
@@ -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 != 30 {
|
||||
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ 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).
|
||||
|
||||
@@ -154,6 +154,28 @@ 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))
|
||||
|
||||
@@ -272,6 +272,39 @@ 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() {
|
||||
|
||||
@@ -44,10 +44,17 @@ type Manager struct {
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
// AllowPresets opts this manager into the baked-in OAuth presets without the
|
||||
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
|
||||
// and CLI login (and the runtime token refresh) for a provider the user chose
|
||||
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
|
||||
// flag onto Env (or the process environment when Env is nil), preserving any
|
||||
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
|
||||
AllowPresets bool
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
@@ -83,9 +90,13 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
|
||||
if open == nil {
|
||||
open = func(string) error { return nil }
|
||||
}
|
||||
env := opts.Env
|
||||
if opts.AllowPresets {
|
||||
env = envWithPresetsAllowed(env)
|
||||
}
|
||||
return &Manager{
|
||||
store: opts.Store, registry: registry, client: client,
|
||||
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,30 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
|
||||
return m
|
||||
}
|
||||
|
||||
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
|
||||
// preset provider resolves; without it (and no Env) the env stays nil so callers
|
||||
// and tests remain hermetic.
|
||||
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
|
||||
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
|
||||
}
|
||||
m2, err := NewManager(ManagerOptions{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if m2.env != nil {
|
||||
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoginLoopback(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
package oauth
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
|
||||
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
|
||||
// login for a well-known public client (e.g. xAI) can use the preset without the
|
||||
// operator setting the flag themselves. It copies base — or snapshots the process
|
||||
// environment when base is nil — because envValue treats a non-nil map as hermetic
|
||||
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
|
||||
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
|
||||
func envWithPresetsAllowed(base map[string]string) map[string]string {
|
||||
env := make(map[string]string, len(base)+1)
|
||||
if base == nil {
|
||||
for _, kv := range os.Environ() {
|
||||
if eq := strings.IndexByte(kv, '='); eq > 0 {
|
||||
env[kv[:eq]] = kv[eq+1:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for k, v := range base {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
|
||||
return env
|
||||
}
|
||||
|
||||
// providerPreset is a baked-in default OAuth configuration for a well-known
|
||||
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
|
||||
|
||||
@@ -46,6 +46,46 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
|
||||
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
|
||||
// xAI config now resolves from the baked-in preset.
|
||||
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// Baseline: a hermetic empty map keeps the preset inert.
|
||||
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
|
||||
t.Fatal("xai should not resolve without the opt-in")
|
||||
}
|
||||
// The helper forces the opt-in so the preset resolves.
|
||||
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
|
||||
if err != nil {
|
||||
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
|
||||
t.Fatalf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
|
||||
// overrides — critical because envValue treats a non-nil map as hermetic, so a
|
||||
// partial map would silently drop them.
|
||||
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
|
||||
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
|
||||
env := envWithPresetsAllowed(base)
|
||||
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
|
||||
t.Fatalf("opt-in flag not set: %v", env)
|
||||
}
|
||||
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
|
||||
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
|
||||
}
|
||||
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig with override env: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "custom-id" {
|
||||
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
|
||||
@@ -85,6 +85,104 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
|
||||
// length via its native /api/show endpoint. The generic /v1/models probe
|
||||
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
|
||||
// listings don't carry context-window metadata, and a custom/local model tag
|
||||
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
|
||||
// through the local daemon) has no curated-catalog entry to borrow one from
|
||||
// either, so modelContextWindow has nothing to show for it without this. Only
|
||||
// meaningful for the local Ollama provider (baseURL like
|
||||
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
|
||||
// service and isn't assumed to expose the same endpoint.
|
||||
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return 0, fmt.Errorf("model name is required")
|
||||
}
|
||||
endpoint, err := ollamaShowEndpoint(baseURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Model string `json:"model"`
|
||||
}{Model: model})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return parseOllamaShowContextWindow(body)
|
||||
}
|
||||
|
||||
// ollamaShowEndpoint derives the native Ollama API root from the
|
||||
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
|
||||
// appends /api/show.
|
||||
func ollamaShowEndpoint(baseURL string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
path = strings.TrimSuffix(path, "/v1")
|
||||
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// parseOllamaShowContextWindow extracts the context length from an
|
||||
// /api/show response. Ollama reports it under model_info with an
|
||||
// architecture-prefixed key (e.g. "llama.context_length",
|
||||
// "qwen2.context_length") — the prefix varies by model family, so this scans
|
||||
// for any key ending in ".context_length" rather than hardcoding one.
|
||||
func parseOllamaShowContextWindow(body []byte) (int, error) {
|
||||
// model_info mixes value types (strings like "general.architecture", numbers
|
||||
// like "*.context_length"), so decode values generically and only try to
|
||||
// read a number out of the one key we actually care about.
|
||||
var payload struct {
|
||||
ModelInfo map[string]any `json:"model_info"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, fmt.Errorf("decode ollama show response: %w", err)
|
||||
}
|
||||
for key, value := range payload.ModelInfo {
|
||||
if !strings.HasSuffix(key, ".context_length") {
|
||||
continue
|
||||
}
|
||||
if window, ok := value.(float64); ok && window > 0 {
|
||||
return int(window), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("ollama show response did not report a context length")
|
||||
}
|
||||
|
||||
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
|
||||
endpoint, err := modelsEndpoint(profile.BaseURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package providermodeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -267,3 +268,61 @@ func modelIDs(models []Model) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
|
||||
// /v1/models probe never carries context-window metadata (parseModelsResponse
|
||||
// only extracts id/description), so a custom/local Ollama model tag with no
|
||||
// curated-catalog match has no other source for it. This exercises the
|
||||
// Ollama-native /api/show fallback that fills that gap.
|
||||
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
|
||||
var gotPath, gotMethod, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"model_info": {
|
||||
"general.architecture": "qwen2",
|
||||
"qwen2.context_length": 131072
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
|
||||
}
|
||||
if window != 131072 {
|
||||
t.Fatalf("context window = %d, want 131072", window)
|
||||
}
|
||||
if gotPath != "/api/show" {
|
||||
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
|
||||
t.Fatalf("request body = %q, want it to name the model", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
|
||||
t.Fatal("expected an error for an empty model name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
|
||||
t.Fatal("expected an error when no *.context_length key is present")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
|
||||
state.closeOpen(ctx, events)
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
|
||||
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,6 +264,13 @@ 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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
|
||||
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const (
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventContentPartAdded = "response.content_part.added"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventOutputTextDone = "response.output_text.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventContentPartDone = "response.content_part.done"
|
||||
@@ -86,6 +87,10 @@ type responsesRequest struct {
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
|
||||
// level). Without it the backend emits no reasoning events, so a long thinking
|
||||
// phase produces zero visible output and reads as a hang in the UI.
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -256,7 +261,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -382,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) {
|
||||
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: p.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", inner.streamIdleTimeout)),
|
||||
Error: p.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, inner.streamIdleTimeout)),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -501,6 +508,17 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventReasoningDelta:
|
||||
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
|
||||
// phase shows progress (and keeps the activity clock fresh) instead of
|
||||
// looking like a hang. Requested via reasoning.summary="auto".
|
||||
if event.Delta != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: event.Delta,
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventFunctionArgsDelta:
|
||||
p.handleFunctionArgsDelta(ctx, &event, state, events)
|
||||
return true
|
||||
|
||||
@@ -485,6 +485,58 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
// A reasoning summary must be requested so the backend streams
|
||||
// reasoning_summary_text deltas — otherwise a long think shows no live output.
|
||||
if reasoning["summary"] != "auto" {
|
||||
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
// reasoning phase produces zero visible output and reads as a hang.
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
|
||||
`{"type":"response.output_text.delta","delta":"done"}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
var reasoning, text []string
|
||||
for ev := range stream {
|
||||
switch ev.Type {
|
||||
case zeroruntime.StreamEventReasoning:
|
||||
reasoning = append(reasoning, ev.Content)
|
||||
case zeroruntime.StreamEventText:
|
||||
text = append(text, ev.Content)
|
||||
case zeroruntime.StreamEventError:
|
||||
t.Fatalf("unexpected error event: %q", ev.Error)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
|
||||
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
|
||||
}
|
||||
if got := strings.Join(text, ""); got != "done" {
|
||||
t.Fatalf("text deltas = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
|
||||
@@ -102,10 +102,11 @@ func New(options Options) (*Provider, error) {
|
||||
endpoint = baseURL + "/chat/completions"
|
||||
}
|
||||
|
||||
httpClient := options.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
// 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)
|
||||
|
||||
maxTokens := options.MaxTokens
|
||||
if maxTokens < 0 {
|
||||
@@ -218,12 +219,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) {
|
||||
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
|
||||
state.flushBufferedContent(events)
|
||||
state.closeBufferedOpen(events)
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
|
||||
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,6 +23,26 @@ 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
|
||||
@@ -33,6 +53,14 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
|
||||
// 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
|
||||
@@ -77,12 +105,42 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// HTTPClient returns the configured client or the process default.
|
||||
// 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.
|
||||
func HTTPClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return http.DefaultClient
|
||||
return sharedHTTPClient
|
||||
}
|
||||
|
||||
// SendEvent writes a provider event without blocking cancellation cleanup.
|
||||
@@ -202,21 +260,36 @@ 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 <-chan time.Time
|
||||
var idleC, contentC <-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 = func() {
|
||||
if !idle.Stop() {
|
||||
select {
|
||||
case <-idle.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
idle.Reset(idleTimeout)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -232,6 +305,12 @@ 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,
|
||||
@@ -246,8 +325,11 @@ 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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -201,3 +202,133 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,11 @@ type ChildRunResult struct {
|
||||
Events []streamjson.Event
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Started bool
|
||||
// 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
|
||||
}
|
||||
|
||||
func (executor Executor) Run(ctx context.Context, params TaskParameters, options TaskRunOptions) (ExecResult, error) {
|
||||
@@ -566,17 +570,35 @@ 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)
|
||||
return ExecResult{}, err
|
||||
// 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
|
||||
}
|
||||
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),
|
||||
Result: BuildFinalResult(run.Events, run.Stderr, run.ExitCode, run.Signal),
|
||||
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 == "" {
|
||||
@@ -592,7 +614,12 @@ func (executor Executor) loadManifest(name string) (Manifest, error) {
|
||||
}
|
||||
manifest, ok := Find(result, name)
|
||||
if !ok {
|
||||
return Manifest{}, fmt.Errorf("specialist %q not found", name)
|
||||
// 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, nil
|
||||
}
|
||||
@@ -783,15 +810,22 @@ 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, Started: started}, nil
|
||||
return ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: exitCode, Signal: signalDesc, Started: started}, nil
|
||||
}
|
||||
|
||||
func (run ChildRunResult) exitCodeOr(defaultExitCode int) int {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,12 @@ 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 != "" {
|
||||
|
||||
@@ -134,12 +134,17 @@ func SummarizeStream(events []streamjson.Event, processExitCode int) StreamResul
|
||||
return result
|
||||
}
|
||||
|
||||
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int) tools.Result {
|
||||
func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExitCode int, signalDesc string) 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 != "" {
|
||||
@@ -149,7 +154,15 @@ func BuildFinalResult(events []streamjson.Event, stderrOutput string, processExi
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
fmt.Fprintf(&builder, "Subagent failed (exit %d)\n", summary.ExitCode)
|
||||
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)
|
||||
}
|
||||
if len(summary.Errors) > 0 {
|
||||
fmt.Fprintf(&builder, "errors: %s\n", strings.Join(summary.Errors, "; "))
|
||||
}
|
||||
|
||||
@@ -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,6 +93,33 @@ 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") {
|
||||
|
||||
@@ -166,6 +166,12 @@ 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()
|
||||
|
||||
@@ -2,8 +2,10 @@ package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// NewSpecialistLauncher adapts internal/specialist.Executor into a
|
||||
@@ -54,7 +56,18 @@ func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher {
|
||||
MemberAutonomy: true,
|
||||
})
|
||||
if err != nil {
|
||||
return MemberResult{}, err
|
||||
// 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{Result: res.Result.Output, SessionID: res.SessionID}, nil
|
||||
}}
|
||||
|
||||
@@ -67,3 +67,50 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,9 @@ func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
|
||||
}
|
||||
// fall through to record the original error if relaunch fails
|
||||
}
|
||||
_ = s.coord.Fail(m.TaskID, memberError(err))
|
||||
// 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)
|
||||
} else {
|
||||
_ = s.coord.CompleteWithSession(m.TaskID, res.Result, res.SessionID)
|
||||
}
|
||||
|
||||
+21
-2
@@ -103,6 +103,25 @@ 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 {
|
||||
@@ -118,7 +137,7 @@ func (t *spawnTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"agent_type": {Type: "string", Description: "Roster agent type to spawn (e.g. teammate, subagent)."},
|
||||
"agent_type": agentTypeProperty(t.sw, "Roster agent type to spawn."),
|
||||
"task": {Type: "string", Description: "The focused task/briefing for the member."},
|
||||
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
|
||||
},
|
||||
@@ -353,7 +372,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": {Type: "string", Description: "Roster agent type to take over."},
|
||||
"to_agent_type": agentTypeProperty(t.sw, "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\"."},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +11,46 @@ 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))
|
||||
|
||||
+144
-54
@@ -28,8 +28,17 @@ const (
|
||||
completedSessionRetention = 30 * time.Second
|
||||
maxExecSessions = 64
|
||||
recentExecOutputBytes = 4096
|
||||
execSessionStopTimeout = 3 * time.Second
|
||||
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
|
||||
// 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"
|
||||
)
|
||||
|
||||
type execSessionManager struct {
|
||||
@@ -233,6 +242,10 @@ 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 {
|
||||
@@ -317,24 +330,26 @@ 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(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
type execOutputBuffer struct {
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
recent []byte
|
||||
notify chan struct{}
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
recent []byte
|
||||
truncated bool
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func newExecOutputBuffer() *execOutputBuffer {
|
||||
@@ -344,6 +359,10 @@ 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:]
|
||||
@@ -373,6 +392,34 @@ 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
|
||||
@@ -485,25 +532,28 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
if err != nil {
|
||||
return errorResult("Error starting exec_command: " + err.Error())
|
||||
}
|
||||
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
|
||||
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
|
||||
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
|
||||
session.terminate()
|
||||
output += session.collect(context.Background(), time.Second)
|
||||
more, moreTruncated := session.collect(context.Background(), time.Second)
|
||||
output += more
|
||||
outputTruncated = outputTruncated || moreTruncated
|
||||
}
|
||||
exitCode, exited := session.exitStatus()
|
||||
if exited {
|
||||
tool.manager.remove(session.id)
|
||||
}
|
||||
return execToolResult(execToolResultInput{
|
||||
commandText: commandText,
|
||||
output: output,
|
||||
sessionID: session.id,
|
||||
exitCode: exitCode,
|
||||
exited: exited,
|
||||
relativeCwd: relativeCwd,
|
||||
tty: session.tty,
|
||||
plan: session.plan,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
commandText: commandText,
|
||||
output: output,
|
||||
outputBufferTruncated: outputTruncated,
|
||||
sessionID: session.id,
|
||||
exitCode: exitCode,
|
||||
exited: exited,
|
||||
relativeCwd: relativeCwd,
|
||||
tty: session.tty,
|
||||
plan: session.plan,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -558,26 +608,49 @@ func (tool execCommandTool) startSession(commandText string, absoluteCwd string,
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (session *execSession) collect(ctx context.Context, wait time.Duration) string {
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
// A background process that keeps writing output faster than this
|
||||
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
|
||||
// a crash-restart spew) would otherwise never let drainString return
|
||||
// empty, so the deadline check below — only reached on an empty
|
||||
// drain — would never fire. That turns `wait` into an unbounded
|
||||
// block regardless of what the caller asked for. Check it here too,
|
||||
// so continuous output can never starve the timeout.
|
||||
if time.Now().After(deadline) {
|
||||
return finish()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if session.doneClosed() {
|
||||
if chunk := session.output.drainString(); chunk != "" {
|
||||
builder.WriteString(chunk)
|
||||
}
|
||||
return builder.String()
|
||||
return finish()
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return builder.String()
|
||||
return finish()
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
@@ -602,9 +675,9 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) str
|
||||
default:
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
return finish()
|
||||
case <-timer.C:
|
||||
return builder.String()
|
||||
return finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -701,22 +774,23 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
}
|
||||
}
|
||||
}
|
||||
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
|
||||
output, outputTruncated := 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,
|
||||
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,
|
||||
outputBufferTruncated: outputTruncated,
|
||||
sessionID: session.id,
|
||||
exitCode: exitCode,
|
||||
exited: exited,
|
||||
relativeCwd: session.relativeCwd,
|
||||
tty: session.tty,
|
||||
interrupted: interrupted,
|
||||
plan: session.plan,
|
||||
maxOutputTokens: maxOutputTokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -741,16 +815,22 @@ func shouldInterruptExecSession(chars string, tty bool) bool {
|
||||
}
|
||||
|
||||
type execToolResultInput struct {
|
||||
commandText string
|
||||
output string
|
||||
sessionID int
|
||||
exitCode int
|
||||
exited bool
|
||||
relativeCwd string
|
||||
tty bool
|
||||
interrupted bool
|
||||
plan zeroSandbox.CommandPlan
|
||||
maxOutputTokens int
|
||||
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
|
||||
}
|
||||
|
||||
func execToolResult(input execToolResultInput) Result {
|
||||
@@ -771,16 +851,26 @@ 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,
|
||||
Truncated: truncated || input.outputBufferTruncated,
|
||||
Meta: meta,
|
||||
Display: Display{
|
||||
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
|
||||
|
||||
@@ -363,6 +363,152 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
|
||||
// close to its requested deadline even while output keeps arriving. Before
|
||||
// the corresponding fix, the deadline was only checked in the branch reached
|
||||
// when drainString returns empty — a background process producing output
|
||||
// fast/continuously enough to keep the buffer perpetually non-empty could
|
||||
// theoretically starve that check indefinitely. This synthetic writer
|
||||
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
|
||||
// Go's scheduler in practice — the reader still won the race often enough —
|
||||
// so this test doesn't prove the old code could hang; it just pins down the
|
||||
// intended behavior (bounded by wait) going forward.
|
||||
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
||||
session := &execSession{
|
||||
id: 1000,
|
||||
output: newExecOutputBuffer(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
// Several concurrent writers, each pushing a decent-sized chunk in a tight
|
||||
// loop: a single slow writer lets the reader win the race often enough
|
||||
// (the runtime schedules a gap between writes) to mask the bug. Enough
|
||||
// parallel writers keep the buffer non-empty essentially continuously,
|
||||
// closer to a real chatty process whose PTY reads land in bursts.
|
||||
const writers = 8
|
||||
chunk := []byte(strings.Repeat("x", 256))
|
||||
wg.Add(writers)
|
||||
for i := 0; i < writers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
session.output.Write(chunk)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
const wait = 200 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, _ = session.collect(context.Background(), wait)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Generous slack over `wait` for scheduling jitter under a continuously
|
||||
// writing goroutine — this must stay a small multiple of wait, not
|
||||
// "however long the writer keeps going" (which is what the bug produced:
|
||||
// this test would hang past the 30s test timeout without the fix).
|
||||
if elapsed > 3*wait {
|
||||
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
|
||||
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
|
||||
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
|
||||
|
||||
@@ -146,6 +146,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ func pasteFromClipboardCmd() tea.Cmd {
|
||||
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
|
||||
// the paste; empty content is a no-op.
|
||||
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// A paste is a deliberate action, same as a keypress or click — it means
|
||||
// the user moved on to something else, so it disarms a stale Esc
|
||||
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
|
||||
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
|
||||
// do it once here, before any early return, so both the bracketed-paste
|
||||
// and right-click-paste paths are covered uniformly.
|
||||
m = m.disarmCancelConfirmation()
|
||||
if content == "" {
|
||||
// Empty text clipboard — the user may have pasted a screenshot.
|
||||
// Probe the OS clipboard for image content asynchronously.
|
||||
|
||||
@@ -441,6 +441,8 @@ 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) {
|
||||
@@ -488,16 +490,16 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// picker calls this when a model from a non-active provider is chosen, so the
|
||||
// picker can list every saved provider and switch across them (like a unified
|
||||
// provider+model selector). The key is loaded from the encrypted store / env.
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
|
||||
if m.pending {
|
||||
return m, "Model\nCannot switch providers while a run is active."
|
||||
return m, "Model\nCannot switch providers while a run is active.", nil
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session."
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
|
||||
}
|
||||
target, ok := m.savedProviderByName(providerName)
|
||||
if !ok {
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName)
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
|
||||
}
|
||||
target = m.profileWithCredential(target)
|
||||
target.Model = strings.TrimSpace(modelID)
|
||||
@@ -506,21 +508,35 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string)
|
||||
// was deleted/unreadable the marker can still be set, and building a keyless
|
||||
// provider would only fail later with a 401. Local/no-auth providers need no key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
if err != nil {
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
|
||||
}
|
||||
m.provider = next
|
||||
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)
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
|
||||
// Warm discovery for the provider we just switched to, same as Init() does
|
||||
// for the provider active at launch — otherwise the context-usage gauge has
|
||||
// no window for this provider until /model happens to be opened separately.
|
||||
var cmds []tea.Cmd
|
||||
if hasDescriptor {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
commandImage
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -241,6 +242,13 @@ 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",
|
||||
@@ -273,9 +281,9 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/theme",
|
||||
usage: "/theme [auto|dark|light]",
|
||||
usage: "/theme [list|auto|name]",
|
||||
group: commandGroupSession,
|
||||
description: "Switch or show the color theme (auto-detects terminal background).",
|
||||
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
|
||||
kind: commandTheme,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,3 +27,29 @@ func TestContextWindowSegment(t *testing.T) {
|
||||
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
|
||||
// figure and the plain usage segment's token count both read the exact same
|
||||
// latestUsageTokens value — once the gauge is showing it, the usage segment
|
||||
// must fall back to cost-only, or the same number renders twice side by
|
||||
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
|
||||
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
|
||||
if _, err := m.usageTracker.Record(usage.RecordInput{
|
||||
ModelID: "claude-sonnet-4.5",
|
||||
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
status := plainRender(t, m.statusLine(110))
|
||||
if !strings.Contains(status, "161K/200K") {
|
||||
t.Fatalf("status line = %q, expected the context gauge to render", status)
|
||||
}
|
||||
if strings.Count(status, "161K") != 1 {
|
||||
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
|
||||
}
|
||||
if strings.Contains(status, "tok") {
|
||||
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,10 @@ 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))
|
||||
}
|
||||
|
||||
@@ -122,6 +122,8 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if !transcriptContains(next.transcript, "Run cancelled.") {
|
||||
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
|
||||
// delivered automatically on program start, but that's the terminal
|
||||
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
|
||||
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
|
||||
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
|
||||
// back to the unpadded, non-fullscreen render path for the whole session (the
|
||||
// alt-screen viewport never gets filled below the actual content). Init must
|
||||
// explicitly request it too, the same way it already explicitly requests the
|
||||
// background color rather than relying solely on an unprompted push.
|
||||
func TestInitRequestsWindowSize(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
|
||||
cmd := m.Init()
|
||||
if cmd == nil {
|
||||
t.Fatal("Init() must return a non-nil command")
|
||||
}
|
||||
|
||||
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
|
||||
if !batchContainsCmd(t, cmd, want) {
|
||||
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
|
||||
}
|
||||
}
|
||||
|
||||
// batchContainsCmd reports whether cmd (Init()'s single top-level
|
||||
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
|
||||
// (the same technique TestThemeAutoReProbesBackground uses for
|
||||
// tea.RequestBackgroundColor — reliable for named top-level functions like
|
||||
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
|
||||
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
|
||||
// them, since Init()'s batch also carries real closures (e.g. provider model
|
||||
// discovery) that make live network calls and must not fire in a unit test.
|
||||
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
|
||||
t.Helper()
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
if reflect.ValueOf(cmd).Pointer() == want {
|
||||
return true
|
||||
}
|
||||
batch, ok := cmd().(tea.BatchMsg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range batch {
|
||||
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
|
||||
bindings: []keybinding{
|
||||
{"Enter", "send the message"},
|
||||
{"Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
|
||||
+327
-100
@@ -40,41 +40,58 @@ const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
|
||||
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
|
||||
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
|
||||
// window, same amber footer treatment) so the two "stop it" keybinds feel
|
||||
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
|
||||
// suggestion overlay that had already closed — silently threw away an
|
||||
// in-progress run with no chance to reconsider.
|
||||
const escCancelConfirmDuration = 3 * time.Second
|
||||
const escCancelConfirmText = "Press Esc again to cancel"
|
||||
|
||||
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
|
||||
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
|
||||
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
|
||||
// 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
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -280,10 +297,39 @@ type model struct {
|
||||
// (clickable suggestions, right-click paste, transcript select) pause while on.
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
copyStatus string
|
||||
copyStatusSeq int
|
||||
exitConfirmActive bool
|
||||
exitConfirmSeq int
|
||||
// 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
|
||||
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
|
||||
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
|
||||
// cancelling a running turn. The two are deliberately separate state (not a
|
||||
// shared flag) since they're different actions with different consequences
|
||||
// (quit the app vs. cancel the current run) that are armed by different
|
||||
// keys — Ctrl+C and Esc respectively.
|
||||
cancelConfirmActive bool
|
||||
cancelConfirmSeq int
|
||||
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
|
||||
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
|
||||
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
|
||||
// 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
|
||||
|
||||
// picker, when non-nil, is an open interactive selector overlay (/model,
|
||||
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
|
||||
@@ -302,6 +348,12 @@ type model struct {
|
||||
// catalog descriptor ID), so /model shows each provider's real current models —
|
||||
// the same list the provider-setup wizard discovers — not the static catalog.
|
||||
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
|
||||
// ollamaContextWindowByModel holds context-window sizes fetched from a local
|
||||
// Ollama daemon's native /api/show endpoint (keyed by model name), for
|
||||
// custom/local models that have no curated-catalog entry and whose
|
||||
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
|
||||
// see modelContextWindow.
|
||||
ollamaContextWindowByModel map[string]int
|
||||
|
||||
// pendingImages holds image attachments staged by /image for the next user
|
||||
// turn; pendingImageLabels are their display names (base(path)) for the chip
|
||||
@@ -330,6 +382,21 @@ type exitConfirmExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
|
||||
// cancel-a-run confirmation (see cancelConfirmActive).
|
||||
type cancelConfirmExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
|
||||
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
|
||||
// drag moved back into the body, released, or was otherwise stopped since this
|
||||
// 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).
|
||||
@@ -608,56 +675,57 @@ func newModel(ctx context.Context, options Options) model {
|
||||
notifier.SetFocused(true)
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
@@ -722,6 +790,18 @@ func composerBlinkCmd() tea.Cmd {
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
|
||||
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
|
||||
// on program start, so m.height/m.width are normally set before the first
|
||||
// render. But that's the terminal proactively pushing a size — if it's
|
||||
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
|
||||
// nothing else ever asks again: m.height stays its zero value forever,
|
||||
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
|
||||
// unpadded, non-fullscreen render path for the rest of the session, and
|
||||
// the alt-screen viewport never gets filled below the actual content.
|
||||
// Explicitly requesting it here means Zero doesn't depend solely on the
|
||||
// terminal's unprompted push — mirrors the RequestBackgroundColor request
|
||||
// below for the same reason.
|
||||
cmds = append(cmds, tea.RequestWindowSize)
|
||||
// In auto mode, ask the terminal for its background color; the reply arrives
|
||||
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
|
||||
if m.themeMode == themeAuto {
|
||||
@@ -736,6 +816,13 @@ func (m model) Init() tea.Cmd {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// The generic discovery above has no source for a local Ollama model's
|
||||
// context window (see ollamaContextWindowDiscoveryCmd); probe its
|
||||
// native /api/show separately so the gauge works for custom/local
|
||||
// Ollama models too, not just ones in the curated catalog.
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
if m.prService != nil && m.runtimeMessageSink != nil {
|
||||
service := m.prService
|
||||
@@ -812,6 +899,14 @@ func (m model) disarmExitConfirmation() model {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) disarmCancelConfirmation() model {
|
||||
if m.cancelConfirmActive {
|
||||
m.cancelConfirmActive = false
|
||||
m.cancelConfirmSeq++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Update routes every message through updateModel, then advances the flush
|
||||
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
|
||||
// instead of printing into terminal scrollback (see flush.go).
|
||||
@@ -890,6 +985,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.exitConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case cancelConfirmExpiredMsg:
|
||||
if msg.seq == m.cancelConfirmSeq {
|
||||
m.cancelConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case dragEdgeScrollTickMsg:
|
||||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||||
}
|
||||
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
|
||||
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
||||
case providerWizardOAuthMsg:
|
||||
return m.applyProviderWizardOAuth(msg)
|
||||
case providerWizardDeviceCodeMsg:
|
||||
@@ -931,6 +1037,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !keyCtrl(msg, 'c') {
|
||||
m = m.disarmExitConfirmation()
|
||||
}
|
||||
// Mirrors the exit-confirmation reset above: any key that isn't itself
|
||||
// the confirming Esc means the user moved on to something else, so a
|
||||
// later, unrelated Esc must arm a fresh confirmation rather than
|
||||
// silently cancel off a stale press from seconds ago.
|
||||
if !keyIs(msg, tea.KeyEsc) {
|
||||
m = m.disarmCancelConfirmation()
|
||||
}
|
||||
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
|
||||
// other key is swallowed so nothing types into the hidden composer.
|
||||
if m.helpOverlay {
|
||||
@@ -953,9 +1066,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
|
||||
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
|
||||
// reaching the run-cancel fallback. Capture whether this press really
|
||||
// is the confirming second Esc BEFORE any of those branches can fire,
|
||||
// then disarm unconditionally: an Esc that gets consumed by one of
|
||||
// them wasn't a confirm, so it must not leave cancelConfirmActive
|
||||
// armed for some later, unrelated Esc to silently act on.
|
||||
wasConfirmingCancel := m.pending && m.cancelConfirmActive
|
||||
m = m.disarmCancelConfirmation()
|
||||
// Subchat view exits on Esc (returns to main chat).
|
||||
if m.subchat.active {
|
||||
m.chatScrollOffset = m.subchat.exit()
|
||||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||||
return m, nil
|
||||
}
|
||||
if m.mcpCommandCancel != nil {
|
||||
@@ -1003,6 +1126,11 @@ 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
|
||||
}
|
||||
@@ -1012,11 +1140,24 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.hasQueuedMessage() {
|
||||
return m.clearQueuedMessage(), nil
|
||||
}
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
if m.pending {
|
||||
m.cancelRun()
|
||||
if wasConfirmingCancel {
|
||||
m.clearComposer()
|
||||
m.cancelRun()
|
||||
return m, nil
|
||||
}
|
||||
// First Esc only arms the confirmation — preserve whatever
|
||||
// draft the user has typed, since nothing has actually been
|
||||
// cancelled yet and they may not press Esc again.
|
||||
m.cancelConfirmActive = true
|
||||
m.cancelConfirmSeq++
|
||||
seq := m.cancelConfirmSeq
|
||||
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
|
||||
return cancelConfirmExpiredMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
m.clearComposer()
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
if m.transcriptDetailed {
|
||||
@@ -1137,6 +1278,9 @@ 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
|
||||
@@ -1174,11 +1318,17 @@ 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 {
|
||||
@@ -1203,7 +1353,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(1)
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -1220,6 +1370,7 @@ 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 {
|
||||
@@ -1244,7 +1395,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(-1)
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -1303,6 +1454,9 @@ 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
|
||||
}
|
||||
@@ -1445,6 +1599,10 @@ 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
|
||||
@@ -1555,6 +1713,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
|
||||
m.pending = false
|
||||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||||
// Fully reset the fade state at stream end. The next render
|
||||
// emits the final row in solid ink (no settling animation), and
|
||||
// the pending streamingFadeTickMsg that lands after this point
|
||||
@@ -1856,6 +2015,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applySetupOAuthDeviceCode(msg)
|
||||
case modelPickerModelsDiscoveredMsg:
|
||||
return m.applyModelPickerModelsDiscovered(msg), nil
|
||||
case ollamaContextWindowDiscoveredMsg:
|
||||
if msg.err == nil && msg.contextWindow > 0 {
|
||||
if m.ollamaContextWindowByModel == nil {
|
||||
m.ollamaContextWindowByModel = map[string]int{}
|
||||
}
|
||||
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
|
||||
}
|
||||
return m, nil
|
||||
case mcpCommandResultMsg:
|
||||
return m.applyMCPCommandResultMessage(msg), nil
|
||||
}
|
||||
@@ -1879,9 +2046,27 @@ 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() {
|
||||
view.MouseMode = tea.MouseModeCellMotion
|
||||
// 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
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -2030,10 +2215,14 @@ 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.
|
||||
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
|
||||
footer.WriteString(plan)
|
||||
footer.WriteString("\n")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -3199,14 +3388,20 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
item, ok := picker.current()
|
||||
if !ok {
|
||||
if picker.kind == pickerTheme {
|
||||
// No selectable row (e.g. the filter matched nothing): undo any live
|
||||
// preview so the palette matches the committed m.themeMode.
|
||||
m.restoreCommittedTheme()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch picker.kind {
|
||||
case pickerModel:
|
||||
text := ""
|
||||
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
|
||||
// A model from another saved provider: switch provider + model together.
|
||||
m, text = m.switchProviderModel(owner, item.Value)
|
||||
m, text, cmd = m.switchProviderModel(owner, item.Value)
|
||||
} else {
|
||||
m, text = m.handleModelCommand(item.Value)
|
||||
}
|
||||
@@ -3223,8 +3418,20 @@ 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
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
|
||||
@@ -3465,7 +3672,26 @@ 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})
|
||||
@@ -3729,7 +3955,8 @@ func (m *model) cancelRun() {
|
||||
m.pending = false
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
m.pendingPermission = nil
|
||||
m.pendingAskUser = nil
|
||||
// The interim block renders streamingText live; a cancelled run's partial
|
||||
|
||||
@@ -84,6 +84,13 @@ func (m model) modelContextWindow(modelName string) int {
|
||||
return window
|
||||
}
|
||||
}
|
||||
// A custom/local Ollama model tag has no curated-catalog entry, and its
|
||||
// OpenAI-compatible /v1/models listing (the discoveredContextWindow source
|
||||
// above) doesn't carry context-window metadata at all — see
|
||||
// ollamaContextWindowDiscoveryCmd, the only other source for this.
|
||||
if window := m.ollamaContextWindowByModel[trimmed]; window > 0 {
|
||||
return window
|
||||
}
|
||||
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
|
||||
// applies its own positive fallback via modelregistry.AgentContextWindow.
|
||||
return 0
|
||||
|
||||
+203
-7
@@ -1108,7 +1108,87 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscCancelsPendingRun(t *testing.T) {
|
||||
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
cancelled := false
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() { cancelled = true }
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
|
||||
if cancelled {
|
||||
t.Fatal("first Esc should not cancel the pending run")
|
||||
}
|
||||
if !next.pending {
|
||||
t.Fatal("first Esc should leave the run pending")
|
||||
}
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("first Esc should arm cancel confirmation")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("first Esc should schedule confirmation expiry")
|
||||
}
|
||||
status := plainRender(t, next.statusLine(80))
|
||||
if !strings.Contains(status, escCancelConfirmText) {
|
||||
t.Fatalf("status line = %q, want cancel confirmation", status)
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
|
||||
if !cancelled {
|
||||
t.Fatal("second Esc should cancel pending run")
|
||||
}
|
||||
if next.pending {
|
||||
t.Fatal("expected second Esc to clear pending state")
|
||||
}
|
||||
if next.activeRunID != 0 || next.runCancel != nil {
|
||||
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
|
||||
}
|
||||
if next.cancelConfirmActive {
|
||||
t.Fatal("cancelling should clear cancel confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
|
||||
// arms the confirmation — nothing has actually been cancelled yet, so it
|
||||
// must not destroy a draft the user is still typing. Only the confirming
|
||||
// second Esc (the one that actually cancels the run) clears the composer.
|
||||
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() {}
|
||||
m.input.SetValue("draft prompt")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("first Esc should arm cancel confirmation")
|
||||
}
|
||||
if next.composerValue() != "draft prompt" {
|
||||
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
|
||||
if next.pending {
|
||||
t.Fatal("second Esc should cancel the pending run")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
|
||||
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
|
||||
// deliberate action just like typing or clicking — it must disarm a stale
|
||||
// confirmation too, or a later, unrelated Esc could silently cancel the run.
|
||||
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
cancelled := false
|
||||
m.pending = true
|
||||
@@ -1117,15 +1197,131 @@ func TestEscCancelsPendingRun(t *testing.T) {
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("first Esc should arm cancel confirmation")
|
||||
}
|
||||
|
||||
if !cancelled {
|
||||
t.Fatal("expected Esc to cancel pending run")
|
||||
updated, _ = next.Update(testPaste("pasted text"))
|
||||
next = updated.(model)
|
||||
if next.cancelConfirmActive {
|
||||
t.Fatal("a paste should disarm the stale cancel confirmation")
|
||||
}
|
||||
if next.pending {
|
||||
t.Fatal("expected Esc to clear pending state")
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
|
||||
}
|
||||
if next.activeRunID != 0 || next.runCancel != nil {
|
||||
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("Esc after a paste should arm a fresh confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscCancelConfirmationExpires(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() {}
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
seq := next.cancelConfirmSeq
|
||||
|
||||
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
|
||||
next = updated.(model)
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("stale expiry should not clear active cancel confirmation")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
|
||||
next = updated.(model)
|
||||
if next.cancelConfirmActive {
|
||||
t.Fatal("matching expiry should clear cancel confirmation")
|
||||
}
|
||||
if !next.pending {
|
||||
t.Fatal("expiring the confirmation should not cancel the run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
|
||||
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
|
||||
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
|
||||
// so a later, unrelated Esc must arm a fresh confirmation instead of
|
||||
// silently cancelling off a stale press from seconds ago.
|
||||
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
cancelled := false
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() { cancelled = true }
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("first Esc should arm cancel confirmation")
|
||||
}
|
||||
seq := next.cancelConfirmSeq
|
||||
|
||||
updated, _ = next.Update(testKey('h'))
|
||||
next = updated.(model)
|
||||
if next.cancelConfirmActive {
|
||||
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
|
||||
}
|
||||
if next.cancelConfirmSeq == seq {
|
||||
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("Esc after an intervening key should re-arm, not cancel")
|
||||
}
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
|
||||
// prompt lands between the two Esc presses. The user's second Esc denies the
|
||||
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
|
||||
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
|
||||
// silently cancel the run.
|
||||
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
cancelled := false
|
||||
m.pending = true
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() { cancelled = true }
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("first Esc should arm cancel confirmation")
|
||||
}
|
||||
|
||||
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
|
||||
next.pendingAskUser = &pendingAskUserPrompt{
|
||||
request: request,
|
||||
answer: func([]string) {},
|
||||
states: newAskUserStates(request.Questions),
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
|
||||
}
|
||||
if next.cancelConfirmActive {
|
||||
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if cancelled {
|
||||
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
|
||||
}
|
||||
if !next.cancelConfirmActive {
|
||||
t.Fatal("the next Esc should arm a fresh confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-4
@@ -14,6 +14,25 @@ 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
|
||||
@@ -21,6 +40,15 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.providerWizard != nil && m.providerWizard.oauthPending {
|
||||
return m, nil
|
||||
}
|
||||
// A click is a deliberate action, same as a keypress — it means the user
|
||||
// moved on to something else, so it disarms a stale Esc cancel-confirmation
|
||||
// rather than leaving it armed for some later, unrelated Esc to act on.
|
||||
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
|
||||
// continuously while the mouse merely sits over the terminal and would
|
||||
// otherwise defeat the confirmation window on every render tick.
|
||||
if mouseLeftPress(msg) || mouseRightPress(msg) {
|
||||
m = m.disarmCancelConfirmation()
|
||||
}
|
||||
// A right-click pastes the clipboard straight into the focused field — no
|
||||
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
|
||||
// clipboardReadMsg result is routed by routePaste to wherever input is focused
|
||||
@@ -84,10 +112,17 @@ 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
|
||||
@@ -104,7 +139,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(-1)
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -116,9 +151,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
return m.scrollChat(chatWheelScrollLines), nil
|
||||
return m.scrollChatExtendingSelection(chatWheelScrollLines, msg), nil
|
||||
case mouseWheelDown(msg):
|
||||
m.clearMouseSelection()
|
||||
m = m.clearHover()
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard.move(1)
|
||||
return m, nil
|
||||
@@ -135,7 +171,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(1)
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -147,7 +183,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
return m.scrollChat(-chatWheelScrollLines), nil
|
||||
return m.scrollChatExtendingSelection(-chatWheelScrollLines, msg), nil
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
@@ -414,6 +450,9 @@ 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++
|
||||
|
||||
@@ -407,6 +407,245 @@ 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}
|
||||
|
||||
@@ -44,6 +44,9 @@ func oauthDevicePrepare(name string) (oauth.DeviceAuth, oauth.Config, error) {
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
OpenBrowser: browser.OpenURL,
|
||||
// Device-flow providers (e.g. Hugging Face) rely on the baked-in preset for
|
||||
// their endpoints; opt in so the device code can be requested.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return oauth.DeviceAuth{}, oauth.Config{}, err
|
||||
@@ -62,8 +65,9 @@ func oauthDeviceComplete(name string, cfg oauth.Config, auth oauth.DeviceAuth) e
|
||||
return err
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
AllowPresets: true, // preset config is needed to poll/exchange the device token
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -84,8 +88,9 @@ func oauthStoredToken(ctx context.Context, providerID string) string {
|
||||
return ""
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
AllowPresets: true, // refreshing a preset-provider token re-resolves its config
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
|
||||
+35
-30
@@ -20,41 +20,46 @@ import (
|
||||
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
ProviderProfile config.ProviderProfile
|
||||
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
|
||||
FavoriteModels []string
|
||||
RecapsEnabled bool
|
||||
Provider zeroruntime.Provider
|
||||
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
RuntimeMessageSink func(tea.Msg)
|
||||
Registry *tools.Registry
|
||||
SessionStore *sessions.Store
|
||||
SandboxStore *sandbox.GrantStore
|
||||
MCPConfig config.MCPConfig
|
||||
MCPPermissionStore *mcp.PermissionStore
|
||||
MCPTokenStore *mcp.TokenStore
|
||||
MCPCommand func(context.Context, []string) MCPCommandResult
|
||||
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
UsageTracker *usage.Tracker
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
Cwd string
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
ProviderProfile config.ProviderProfile
|
||||
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
|
||||
FavoriteModels []string
|
||||
RecapsEnabled bool
|
||||
Provider zeroruntime.Provider
|
||||
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
RuntimeMessageSink func(tea.Msg)
|
||||
Registry *tools.Registry
|
||||
SessionStore *sessions.Store
|
||||
SandboxStore *sandbox.GrantStore
|
||||
MCPConfig config.MCPConfig
|
||||
MCPPermissionStore *mcp.PermissionStore
|
||||
MCPTokenStore *mcp.TokenStore
|
||||
MCPCommand func(context.Context, []string) MCPCommandResult
|
||||
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
UsageTracker *usage.Tracker
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
PermissionMode agent.PermissionMode
|
||||
ReasoningEffort modelregistry.ReasoningEffort
|
||||
ResponseStyle 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
|
||||
// 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
|
||||
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
pickerModel pickerKind = iota
|
||||
pickerEffort
|
||||
pickerSession
|
||||
pickerTheme
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -564,6 +565,46 @@ type modelPickerModelsDiscoveredMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// ollamaContextWindowDiscoveredMsg carries the result of an async /api/show
|
||||
// probe against a local Ollama daemon (see ollamaContextWindowDiscoveryCmd).
|
||||
// A zero contextWindow (or a non-nil err) means the probe found nothing
|
||||
// usable and the map is left untouched, not zeroed out.
|
||||
type ollamaContextWindowDiscoveredMsg struct {
|
||||
modelName string
|
||||
contextWindow int
|
||||
err error
|
||||
}
|
||||
|
||||
// ollamaContextWindowDiscoveryCmd probes a local Ollama daemon's native
|
||||
// /api/show endpoint for modelName's context length. Scoped to the local
|
||||
// Ollama provider only (catalog ID "ollama", not "ollama-cloud" — a
|
||||
// different hosted service this endpoint isn't assumed to exist on): the
|
||||
// generic /v1/models discovery has no source for this metadata for
|
||||
// custom/local model tags, so without this the context gauge just never
|
||||
// shows for them (see modelContextWindow).
|
||||
func (m model) ollamaContextWindowDiscoveryCmd(descriptor providercatalog.Descriptor, baseURL string, modelName string) tea.Cmd {
|
||||
if descriptor.ID != "ollama" {
|
||||
return nil
|
||||
}
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if modelName == "" || baseURL == "" {
|
||||
return nil
|
||||
}
|
||||
discover := m.discoverOllamaContextWindow
|
||||
if discover == nil {
|
||||
discover = func(ctx context.Context, baseURL string, model string) (int, error) {
|
||||
return providermodeldiscovery.DiscoverOllamaContextWindow(ctx, baseURL, model, providermodeldiscovery.Options{})
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
window, err := discover(ctx, baseURL, modelName)
|
||||
return ollamaContextWindowDiscoveredMsg{modelName: modelName, contextWindow: window, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor) config.ProviderProfile {
|
||||
profile := m.providerProfile
|
||||
normalizeIdentity := profileMatchesProviderBaseURL(profile, provider) ||
|
||||
@@ -726,3 +767,44 @@ 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()
|
||||
}
|
||||
+104
-4
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -639,14 +640,17 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeCommandOpensNoPicker(t *testing.T) {
|
||||
// /theme keeps the existing shell-only message; no picker opens.
|
||||
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)
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/theme")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
m = updated.(model)
|
||||
if m.picker != nil {
|
||||
t.Fatal("/theme should not open a picker")
|
||||
if m.picker == nil || m.picker.kind != pickerTheme {
|
||||
t.Fatalf("expected the theme picker to open, got %#v", m.picker)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,4 +879,100 @@ func TestModelContextWindowResolution(t *testing.T) {
|
||||
if got := m.modelContextWindow(""); got != 0 {
|
||||
t.Fatalf("empty model name should yield 0, got %d", got)
|
||||
}
|
||||
// A custom/local Ollama model tag has no curated-catalog entry and its
|
||||
// generic /v1/models listing carries no window either — the only source is
|
||||
// the Ollama-specific /api/show probe, landed via ollamaContextWindowByModel.
|
||||
m.ollamaContextWindowByModel = map[string]int{"kimi-k2.7-code:cloud": 131072}
|
||||
if got := m.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
|
||||
t.Fatalf("ollama-discovered window should be used for an unregistered model, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama: the /api/show probe
|
||||
// only makes sense against a local Ollama daemon (catalog ID "ollama") — a
|
||||
// different provider like "ollama-cloud" is a distinct hosted service this
|
||||
// endpoint isn't assumed to exist on, and every other provider already has
|
||||
// its context window covered by the curated catalog or /v1/models discovery.
|
||||
func TestOllamaContextWindowDiscoveryCmdScopedToLocalOllama(t *testing.T) {
|
||||
m := model{ctx: context.Background()}
|
||||
|
||||
ollama, ok := providercatalog.Get("ollama")
|
||||
if !ok {
|
||||
t.Fatal("expected \"ollama\" to be a registered catalog descriptor")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", "kimi-k2.7-code:cloud"); cmd == nil {
|
||||
t.Fatal("expected a discovery command for the local ollama provider")
|
||||
}
|
||||
|
||||
ollamaCloud, ok := providercatalog.Get("ollama-cloud")
|
||||
if !ok {
|
||||
t.Fatal("expected \"ollama-cloud\" to be a registered catalog descriptor")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollamaCloud, "https://ollama.com/v1", "qwen3-coder:480b"); cmd != nil {
|
||||
t.Fatal("ollama-cloud is a different hosted service; must not probe /api/show against it")
|
||||
}
|
||||
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "", "kimi-k2.7-code:cloud"); cmd != nil {
|
||||
t.Fatal("an empty base URL should yield no command")
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(ollama, "http://localhost:11434/v1", ""); cmd != nil {
|
||||
t.Fatal("an empty model name should yield no command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOllamaContextWindowDiscoveredMsgPopulatesMap verifies the Update()
|
||||
// handler for the async probe's result actually reaches modelContextWindow's
|
||||
// fallback source, and that a failed/zero probe leaves the map untouched
|
||||
// rather than caching a bogus zero.
|
||||
func TestOllamaContextWindowDiscoveredMsgPopulatesMap(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
|
||||
updated, cmd := m.Update(ollamaContextWindowDiscoveredMsg{modelName: "kimi-k2.7-code:cloud", contextWindow: 131072})
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("applying a discovered window should not schedule further work")
|
||||
}
|
||||
if got := next.modelContextWindow("kimi-k2.7-code:cloud"); got != 131072 {
|
||||
t.Fatalf("modelContextWindow after discovery = %d, want 131072", got)
|
||||
}
|
||||
|
||||
updated, _ = next.Update(ollamaContextWindowDiscoveredMsg{modelName: "other-model", contextWindow: 0, err: errors.New("boom")})
|
||||
next = updated.(model)
|
||||
if got := next.modelContextWindow("other-model"); got != 0 {
|
||||
t.Fatalf("a failed probe must not populate a window, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwitchProviderModelWarmsDiscoveryForTheNewProvider: switching providers
|
||||
// mid-session (e.g. via the /model picker) previously fired no discovery at
|
||||
// all for the newly active provider — Init() only warms the provider active
|
||||
// at launch — so the context gauge stayed blank for the rest of the session
|
||||
// unless /model happened to be reopened separately. Switching to Ollama
|
||||
// specifically must also warm the /api/show probe, the only source of a
|
||||
// context window for custom/local Ollama models.
|
||||
func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-5.1",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "openai", CatalogID: "openai", Model: "gpt-5.1"},
|
||||
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !strings.Contains(text, "Switched to ollama") {
|
||||
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
|
||||
}
|
||||
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
|
||||
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,10 @@ func runProviderTokenLogin(name string) error {
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
OpenBrowser: browser.OpenURL,
|
||||
// The user explicitly chose to sign in with this provider's OAuth, so opt
|
||||
// into its baked-in preset (e.g. xAI's public client_id); without this the
|
||||
// config never resolves and the browser never opens.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1229,9 +1233,29 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
|
||||
for offset, provider := range wizard.providers[start : start+maxVisible] {
|
||||
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
|
||||
}
|
||||
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
|
||||
// so the error must render here or the click looks like a silent no-op. The
|
||||
// credential step renders its own copy for the ctrl+o path.
|
||||
if wizard.oauthMode && wizard.oauthErr != "" {
|
||||
lines = append(lines, "", zeroTheme.red.Render("OAuth login failed: "+wizard.oauthErr))
|
||||
if hint := providerWizardOAuthErrHint(wizard.currentProvider()); hint != "" {
|
||||
lines = append(lines, zeroTheme.faint.Render(hint))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
|
||||
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
|
||||
// can only work once the operator registers an app and supplies its client_id.
|
||||
func providerWizardOAuthErrHint(provider providercatalog.Descriptor) string {
|
||||
if strings.EqualFold(provider.ID, "huggingface") {
|
||||
return "Hugging Face needs your own app: create one at " +
|
||||
"https://huggingface.co/settings/applications/new, then set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderSelectableProvider(width int, index int, provider providercatalog.Descriptor) string {
|
||||
selected := index == wizard.selectedProvider
|
||||
surface := transparentSurface
|
||||
|
||||
@@ -125,6 +125,36 @@ func TestProviderWizardDeviceCodeMsgShowsCodeAndPolls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A failed OAuth attempt leaves the wizard on the provider list; the error must be
|
||||
// rendered there (not just on the credential step) so a click isn't a silent
|
||||
// no-op, and Hugging Face gets an actionable client_id hint.
|
||||
func TestProviderStepSurfacesOAuthError(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
wizard := m.providerWizard
|
||||
wizard.oauthMode = true
|
||||
wizard.providers = providerWizardOAuthDescriptors()
|
||||
found := false
|
||||
for i, p := range wizard.providers {
|
||||
if strings.EqualFold(p.ID, "huggingface") {
|
||||
wizard.selectedProvider = i
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("huggingface should be an OAuth-capable provider")
|
||||
}
|
||||
wizard.oauthErr = `oauth: provider "huggingface" is not configured; set ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`
|
||||
|
||||
view := strings.Join(wizard.renderProviderStep(72), "\n")
|
||||
if !strings.Contains(view, "OAuth login failed") {
|
||||
t.Fatalf("provider step should surface the OAuth error:\n%s", view)
|
||||
}
|
||||
if !strings.Contains(view, "huggingface.co/settings/applications/new") {
|
||||
t.Fatalf("Hugging Face hint should point at app registration:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderWizardDeviceErrorSurfaced(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.providerWizard = m.newProviderWizard()
|
||||
|
||||
@@ -36,7 +36,7 @@ func pickerBusyText(name string) string {
|
||||
Title: "Busy",
|
||||
Lines: []string{"Can't change " + label + " while a run is in progress."},
|
||||
}},
|
||||
Hints: []string{"press Esc to cancel the run, then try again"},
|
||||
Hints: []string{"press Esc twice to cancel the run, then try again"},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -256,6 +257,42 @@ 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 {
|
||||
|
||||
@@ -775,6 +775,8 @@ func TestEscCancelRecordsSessionError(t *testing.T) {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
}
|
||||
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
|
||||
@@ -836,7 +838,10 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
|
||||
next = updated.(model)
|
||||
if _, ok := runtimeMsg.(permissionRequestMsg); ok {
|
||||
// Cancel mid-run via Esc while the permission prompt is pending: this
|
||||
// unblocks the goroutine through ctx cancellation.
|
||||
// unblocks the goroutine through ctx cancellation. Esc now requires a
|
||||
// second press within the confirmation window to actually cancel.
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
cancelled = true
|
||||
|
||||
+40
-7
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -261,13 +262,11 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
|
||||
switch a.state {
|
||||
case "done", "failed", "completed", "cancelled":
|
||||
a.finishing = true
|
||||
if m.pending {
|
||||
// Run still going: keep it visible and clickable, no fade.
|
||||
a.finishedAt = time.Time{}
|
||||
live = append(live, a)
|
||||
continue
|
||||
}
|
||||
// Turn ended: fade out over the linger window, then drop.
|
||||
// A member drops once ITS OWN task completes — not when the whole turn
|
||||
// ends: fade out over the linger window from when it was first seen
|
||||
// finished (stamped each tick by stampSwarmDone), then remove. This
|
||||
// holds whether or not the overall run is still in flight, mirroring
|
||||
// how finished specialists drop (sidebarSpecialists).
|
||||
doneAt, stamped := m.swarmDoneAt[a.id]
|
||||
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
|
||||
continue // past the linger window — remove
|
||||
@@ -609,6 +608,15 @@ 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)
|
||||
@@ -622,6 +630,31 @@ 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
|
||||
|
||||
@@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) {
|
||||
// row (e.g. a resumed transcript that dropped the call): the member is still
|
||||
// A finished member stays visible (and clickable) while the run is still in
|
||||
// flight, so the user can inspect it; only once the turn ends does it drop.
|
||||
func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
|
||||
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) {
|
||||
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||
m := sidebarTestModel()
|
||||
m.now = func() time.Time { return base }
|
||||
m.pending = true // run still going
|
||||
m.pending = true // run STILL going — a member must still drop on its OWN completion
|
||||
m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7},
|
||||
)
|
||||
// Long past the linger window — but it must still show while pending.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
|
||||
|
||||
// Just finished, still within the linger window: shows briefly with a fading ✓.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
|
||||
agents := m.swarmSpawnedAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
|
||||
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
|
||||
}
|
||||
if !agents[0].finishing {
|
||||
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
|
||||
}
|
||||
|
||||
// Once the turn ends, the long-finished member fades out and drops.
|
||||
m.pending = false
|
||||
// Past the linger window: it drops — even though the overall run is still in
|
||||
// flight (previously a finished member lingered until the whole turn ended).
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)}
|
||||
if got := len(m.swarmSpawnedAgents()); got != 0 {
|
||||
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
|
||||
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
+14
-71
@@ -12,7 +12,8 @@ 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 this file.
|
||||
// these named styles — no hex literal may appear outside theme_palettes.go (the
|
||||
// palette tables + theme registry).
|
||||
type tuiTheme struct {
|
||||
// Base tokens.
|
||||
ink lipgloss.Style // primary text
|
||||
@@ -29,6 +30,7 @@ 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
|
||||
@@ -91,9 +93,10 @@ type tuiTheme struct {
|
||||
}
|
||||
|
||||
// palette is the raw color-token table for one theme. buildTheme turns it into a
|
||||
// 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.
|
||||
// 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.
|
||||
type palette struct {
|
||||
panel string // card backgrounds (the terminal canvas itself is never painted full-bleed)
|
||||
promptBg string // submitted user prompt background
|
||||
@@ -124,73 +127,6 @@ 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) }
|
||||
@@ -210,6 +146,13 @@ 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),
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// themeMode is the operator's palette preference.
|
||||
type themeMode string
|
||||
@@ -11,34 +15,49 @@ const (
|
||||
themeLight themeMode = "light"
|
||||
)
|
||||
|
||||
// themeModes lists the values /theme accepts.
|
||||
var themeModes = []string{string(themeAuto), string(themeDark), string(themeLight)}
|
||||
// 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()...)
|
||||
|
||||
// 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":
|
||||
// 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) {
|
||||
return themeAuto
|
||||
}
|
||||
if _, ok := lookupTheme(s); ok {
|
||||
return themeMode(s)
|
||||
}
|
||||
}
|
||||
return themeAuto
|
||||
}
|
||||
|
||||
// validThemeMode reports whether s names a theme mode (for /theme validation).
|
||||
// validThemeMode reports whether s names a theme mode (for /theme validation):
|
||||
// `auto` or any registered theme.
|
||||
func validThemeMode(s string) bool {
|
||||
switch themeMode(strings.ToLower(strings.TrimSpace(s))) {
|
||||
case themeAuto, themeDark, themeLight:
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == string(themeAuto) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
_, ok := lookupTheme(s)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -53,12 +72,13 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
|
||||
resolved = themeLight
|
||||
}
|
||||
}
|
||||
switch resolved {
|
||||
case themeLight:
|
||||
zeroTheme = buildTheme(lightPalette)
|
||||
default:
|
||||
zeroTheme = buildTheme(darkPalette)
|
||||
// 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))
|
||||
}
|
||||
zeroTheme = buildTheme(entry.Palette)
|
||||
rebuildStreamingFadePalette()
|
||||
if defaultRenderCache != nil {
|
||||
defaultRenderCache.clear() // old-palette entries must not be reused
|
||||
@@ -66,15 +86,45 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
|
||||
return resolved
|
||||
}
|
||||
|
||||
// handleThemeCommand implements /theme [auto|dark|light]: no arg shows state, a
|
||||
// valid mode switches the active palette live. Mirrors handleStyleCommand.
|
||||
// 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.
|
||||
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 auto, dark, or light)"
|
||||
return m, "Theme\nUnknown theme: " + arg + " (use /theme with no argument to pick from the list)"
|
||||
}
|
||||
m.themeMode = themeMode(arg)
|
||||
resolved := applyTheme(m.themeMode, m.hasDarkBg)
|
||||
@@ -82,11 +132,31 @@ func (m model) handleThemeCommand(args string) (model, string) {
|
||||
if m.themeMode == themeAuto {
|
||||
active = "auto (" + string(resolved) + ")"
|
||||
}
|
||||
return m, strings.Join([]string{
|
||||
lines := []string{
|
||||
"Theme",
|
||||
"active theme: " + active,
|
||||
"Already-printed scrollback keeps its previous colors; new output uses the new theme.",
|
||||
}, "\n")
|
||||
}
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// themeStateText renders the /theme state view.
|
||||
@@ -109,6 +179,6 @@ func (m model) themeStateText() string {
|
||||
"available: " + strings.Join(themeModes, ", "),
|
||||
},
|
||||
}},
|
||||
Hints: []string{"use /theme <auto|dark|light> to switch this TUI session"},
|
||||
Hints: []string{"run /theme with no argument to open the picker, or /theme <name> to switch directly"},
|
||||
})
|
||||
}
|
||||
@@ -52,24 +52,19 @@ 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 _, 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)
|
||||
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)
|
||||
}
|
||||
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 r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
|
||||
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
|
||||
}
|
||||
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.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.delBgWord, c.pal.delBg); sep < 1.2 {
|
||||
t.Errorf("%s: delBgWord vs delBg 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,18 +73,52 @@ 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 _, 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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,20 @@ type transcriptSelectionPoint struct {
|
||||
|
||||
type transcriptSelectionState struct {
|
||||
active bool
|
||||
anchor transcriptSelectionPoint
|
||||
cursor transcriptSelectionPoint
|
||||
// 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
|
||||
}
|
||||
|
||||
type transcriptSelectableLine struct {
|
||||
@@ -159,10 +171,45 @@ 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
|
||||
@@ -968,16 +1015,46 @@ 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.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
if m.transcriptHitTestBlocked() {
|
||||
return transcriptSelectableLine{}, false
|
||||
}
|
||||
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)
|
||||
frame, window, layout := m.transcriptHitTestLayout()
|
||||
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
|
||||
if !ok {
|
||||
return transcriptSelectableLine{}, false
|
||||
@@ -995,6 +1072,146 @@ 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)
|
||||
@@ -1029,6 +1246,7 @@ 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
|
||||
@@ -1058,6 +1276,7 @@ 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 {
|
||||
@@ -1070,21 +1289,57 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
point := transcriptSelectionPointForMouse(line, mouseX(msg))
|
||||
m.copyStatus = ""
|
||||
m.transcriptSelection = transcriptSelectionState{active: true, anchor: point, cursor: point}
|
||||
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()
|
||||
return m, nil, true
|
||||
case mouseMotion(msg):
|
||||
if !m.transcriptSelection.active {
|
||||
// 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 {
|
||||
return m, nil, false
|
||||
}
|
||||
line, ok := m.transcriptLineAtMouse(msg)
|
||||
if ok {
|
||||
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
||||
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
|
||||
m = m.stopEdgeScroll() // back inside the body: any running glide ends
|
||||
return m, nil, true
|
||||
}
|
||||
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
|
||||
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))
|
||||
}
|
||||
@@ -1093,6 +1348,10 @@ 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
|
||||
@@ -1113,8 +1372,11 @@ func (m model) toggleTranscriptRow(rowIndex int) model {
|
||||
}
|
||||
|
||||
func (m model) selectedTranscriptText() string {
|
||||
width := m.chatColumnWidth()
|
||||
layout := m.transcriptBodyLayout(width, "")
|
||||
// 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)
|
||||
parts := []string{}
|
||||
for _, line := range layout.selectable {
|
||||
start, end, ok := m.selectedColumnsForTranscriptLine(line)
|
||||
|
||||
@@ -5,6 +5,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+12
-3
@@ -201,6 +201,9 @@ func (m model) statusLine(width int) string {
|
||||
if m.exitConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
|
||||
}
|
||||
if m.cancelConfirmActive {
|
||||
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
|
||||
@@ -210,6 +213,8 @@ func (m model) statusLine(width int) string {
|
||||
}
|
||||
if m.exitConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
|
||||
} else if m.cancelConfirmActive {
|
||||
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
|
||||
} else if summary := m.backgroundTerminalSummary(); summary != "" {
|
||||
left += separator + zeroTheme.muted.Render(summary)
|
||||
}
|
||||
@@ -218,15 +223,19 @@ func (m model) statusLine(width int) string {
|
||||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||||
// most), but skip it when the context sidebar is already showing the % so the
|
||||
// figure isn't duplicated.
|
||||
gaugeShown := false
|
||||
if tier >= tierNarrow && !m.sidebarActive() {
|
||||
if gauge := m.contextWindowSegment(); gauge != "" {
|
||||
rightGroups = append(rightGroups, gauge)
|
||||
gaugeShown = true
|
||||
}
|
||||
}
|
||||
// The sidebar pins the token readout at its floor, so when it's open keep
|
||||
// only the cost here — otherwise the token figure shows on both sides.
|
||||
// The sidebar pins the token readout at its floor, and the gauge's "used"
|
||||
// figure is the exact same number (both read latestUsageTokens) — either
|
||||
// one showing means the plain usage segment must drop its own token count
|
||||
// to just the cost, or the count renders twice side by side.
|
||||
usage := m.usageStatusSegment()
|
||||
if m.sidebarActive() {
|
||||
if m.sidebarActive() || gaugeShown {
|
||||
usage = m.usageCostSegment()
|
||||
}
|
||||
if usage != "" {
|
||||
|
||||
Reference in new issue
Block a user