mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809b0d3a15 | ||
|
|
196b9afd28 | ||
|
|
4bbe62aedb | ||
|
|
bb3b7544a4 | ||
|
|
b27cde63ca | ||
|
|
5a573bbe34 | ||
|
|
eb4fe8fd4e | ||
|
|
5dc62226bd | ||
|
|
9c016ef2ec |
No files matched your search
+59
-5
@@ -141,17 +141,71 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
}
|
||||
if err != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
// fall through to the shared initial-call network retry below
|
||||
}
|
||||
// Retry a transient network failure on the INITIAL call too, mirroring the
|
||||
// collect-phase retry below: a TLS/dial timeout or connection reset here
|
||||
// means the request never reached the provider, so re-sending after a short
|
||||
// backoff is safe. Reactive compaction above only recovers context-limit
|
||||
// errors, not connectivity ones — without this, a transient call-level
|
||||
// failure returned immediately and skipped retries entirely.
|
||||
for attempt := 1; err != nil && attempt <= maxNetworkRetries &&
|
||||
ctx.Err() == nil && isTransientNetworkError(err.Error()); attempt++ {
|
||||
if options.OnNetworkRetry != nil {
|
||||
options.OnNetworkRetry(attempt, err.Error())
|
||||
}
|
||||
if !sleepWithContext(ctx, networkRetryBackoff(attempt-1)) {
|
||||
break // run cancelled during backoff
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
}
|
||||
if err != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
// A transient failure that survived every retry is a connectivity problem
|
||||
// reaching the provider; make the surfaced error actionable (mirrors the
|
||||
// collect-phase annotation below) instead of a cryptic timeout.
|
||||
if ctx.Err() == nil && isTransientNetworkError(err.Error()) {
|
||||
return result, errors.New(annotateUnreachableProvider(err.Error()))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
|
||||
collectOpts := zeroruntime.CollectOptions{
|
||||
OnText: options.OnText,
|
||||
OnReasoning: options.OnReasoning,
|
||||
OnUsage: options.OnUsage,
|
||||
})
|
||||
}
|
||||
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, collectOpts)
|
||||
// Transparently retry a transient network failure that produced NO output
|
||||
// (TLS/dial timeout, connection reset, …): the request never reached the
|
||||
// model, so re-sending it after a short backoff is safe and usually
|
||||
// succeeds. A failure that already produced text/tool calls is NOT retried
|
||||
// (it would duplicate output), and a cancellation or an HTTP/content error
|
||||
// is left for the normal handling below.
|
||||
for attempt := 1; attempt <= maxNetworkRetries &&
|
||||
ctx.Err() == nil &&
|
||||
collected.Error != "" && streamProducedNoOutput(collected) &&
|
||||
isTransientNetworkError(collected.Error); attempt++ {
|
||||
if options.OnNetworkRetry != nil {
|
||||
options.OnNetworkRetry(attempt, collected.Error)
|
||||
}
|
||||
if !sleepWithContext(ctx, networkRetryBackoff(attempt-1)) {
|
||||
break // run cancelled during backoff
|
||||
}
|
||||
retryStream, retryErr := provider.StreamCompletion(ctx, request)
|
||||
if retryErr != nil {
|
||||
collected = zeroruntime.CollectedStream{Error: retryErr.Error()}
|
||||
continue
|
||||
}
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, collectOpts)
|
||||
}
|
||||
// A transient network failure that survived every retry is a connectivity
|
||||
// problem reaching the provider, not the model. Make the surfaced error
|
||||
// actionable instead of a cryptic "TLS handshake timeout".
|
||||
if ctx.Err() == nil && collected.Error != "" && streamProducedNoOutput(collected) &&
|
||||
isTransientNetworkError(collected.Error) {
|
||||
collected.Error = annotateUnreachableProvider(collected.Error)
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// REACTIVE compaction: the streamed error may also be a context
|
||||
// limit (some providers surface it mid-stream). Compact and retry
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// maxNetworkRetries is how many times a transient network failure that produced
|
||||
// NO output is transparently retried before the turn gives up. Three total
|
||||
// attempts (initial + 2 retries) covers a brief blip without stalling for long.
|
||||
const maxNetworkRetries = 2
|
||||
|
||||
// transientNetworkSignals are substrings of error messages that indicate a
|
||||
// connection-level failure worth retrying — the request never reached the model
|
||||
// or got no response, so re-sending the SAME request is safe and may succeed.
|
||||
// Matched case-insensitively against the provider's error string.
|
||||
var transientNetworkSignals = []string{
|
||||
"tls handshake timeout",
|
||||
"i/o timeout",
|
||||
"dial tcp",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"broken pipe",
|
||||
"network is unreachable",
|
||||
"no route to host",
|
||||
"unexpected eof",
|
||||
"server misbehaving", // transient resolver failure
|
||||
"temporary failure in name resolution", // transient DNS
|
||||
}
|
||||
|
||||
// isTransientNetworkError reports whether a provider error string looks like a
|
||||
// retryable connection-level failure. A user cancellation, a context deadline,
|
||||
// or any HTTP/content error (auth, rate limit, context-length) returns false so
|
||||
// only genuine network blips are retried.
|
||||
func isTransientNetworkError(reason string) bool {
|
||||
lower := strings.ToLower(reason)
|
||||
// Never retry a cancellation or a deadline — those are intentional/terminal.
|
||||
if strings.Contains(lower, "context canceled") || strings.Contains(lower, "context deadline exceeded") {
|
||||
return false
|
||||
}
|
||||
// Never retry an explicit HTTP status error (auth, rate limit, bad request,
|
||||
// context length, etc.) — re-sending won't help.
|
||||
if strings.Contains(lower, "status code") || strings.Contains(lower, "status:") ||
|
||||
strings.Contains(lower, "http 4") || strings.Contains(lower, "http 5") {
|
||||
return false
|
||||
}
|
||||
for _, signal := range transientNetworkSignals {
|
||||
if strings.Contains(lower, signal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// streamProducedNoOutput reports whether a collected stream surfaced nothing the
|
||||
// user would see: no text, no tool calls, no reasoning blocks, and no dropped
|
||||
// tool-call signals. The no-output network retry only fires when this holds — a
|
||||
// stream that already produced ANY of these must not be re-sent, since
|
||||
// re-collecting it would duplicate already-surfaced output and contradict the
|
||||
// "no output" retry contract.
|
||||
func streamProducedNoOutput(c zeroruntime.CollectedStream) bool {
|
||||
return c.Text == "" && len(c.ToolCalls) == 0 &&
|
||||
len(c.ReasoningBlocks) == 0 && c.DroppedToolCalls == 0
|
||||
}
|
||||
|
||||
// networkRetryBackoff is the wait before the (0-based) attempt-th retry. It is a
|
||||
// var so tests can zero it; production uses defaultNetworkRetryBackoff.
|
||||
var networkRetryBackoff = defaultNetworkRetryBackoff
|
||||
|
||||
// defaultNetworkRetryBackoff waits exponentially — 1s, 2s, 4s — capped at 8s.
|
||||
func defaultNetworkRetryBackoff(attempt int) time.Duration {
|
||||
d := time.Second << attempt
|
||||
if d > 8*time.Second {
|
||||
d = 8 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// annotateUnreachableProvider turns a bare network error (e.g. "TLS handshake
|
||||
// timeout") into an actionable message once every retry has failed: it's a
|
||||
// connectivity problem reaching the provider, not the model.
|
||||
func annotateUnreachableProvider(reason string) string {
|
||||
return strings.TrimSpace(reason) +
|
||||
"\n\nCouldn't reach the provider after retrying — this is a network/connectivity problem, not the model. " +
|
||||
"Check your internet or VPN, or switch to a reachable provider with /provider."
|
||||
}
|
||||
|
||||
// sleepWithContext waits for d, returning false if the context is cancelled
|
||||
// first (so a backoff never outlives a cancelled run).
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// callErrorProvider returns a CALL-level error (StreamCompletion's error return,
|
||||
// i.e. a connection failure before any stream opens) for the first errCalls
|
||||
// calls, then streams ok. This is distinct from mockProvider, which only ever
|
||||
// surfaces errors as in-stream events.
|
||||
type callErrorProvider struct {
|
||||
errCalls int
|
||||
err string
|
||||
ok []zeroruntime.StreamEvent
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *callErrorProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.calls++
|
||||
if p.calls <= p.errCalls {
|
||||
return nil, errors.New(p.err)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, len(p.ok))
|
||||
for _, event := range p.ok {
|
||||
ch <- event
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunAnnotatesUnreachableProviderAfterRetries(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
timeout := zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: `provider stream error: Post "https://ollama.com/v1/chat/completions": net/http: TLS handshake timeout`}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{timeout}, {timeout}, {timeout}}}
|
||||
|
||||
_, err := Run(context.Background(), "build", provider, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error once every retry failed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/provider") || !strings.Contains(err.Error(), "network/connectivity") {
|
||||
t.Fatalf("error should be an actionable network message, got %q", err.Error())
|
||||
}
|
||||
if len(provider.requests) != 3 { // initial + maxNetworkRetries(2)
|
||||
t.Fatalf("expected 3 attempts, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransientNetworkError(t *testing.T) {
|
||||
transient := []string{
|
||||
`provider stream error: Post "https://ollama.com/v1/chat/completions": net/http: TLS handshake timeout`,
|
||||
"dial tcp 1.2.3.4:443: i/o timeout",
|
||||
"read tcp: connection reset by peer",
|
||||
"connection refused",
|
||||
"unexpected EOF",
|
||||
"network is unreachable",
|
||||
}
|
||||
for _, reason := range transient {
|
||||
if !isTransientNetworkError(reason) {
|
||||
t.Errorf("isTransientNetworkError(%q) = false, want true", reason)
|
||||
}
|
||||
}
|
||||
terminal := []string{
|
||||
"context canceled",
|
||||
"context deadline exceeded",
|
||||
"provider error: status code 401: invalid api key",
|
||||
"HTTP 429: rate limit exceeded",
|
||||
"context length exceeded: 200000 tokens",
|
||||
"invalid request: model not found",
|
||||
}
|
||||
for _, reason := range terminal {
|
||||
if isTransientNetworkError(reason) {
|
||||
t.Errorf("isTransientNetworkError(%q) = true, want false", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNetworkRetryBackoff(t *testing.T) {
|
||||
for attempt, want := range map[int]time.Duration{0: time.Second, 1: 2 * time.Second, 2: 4 * time.Second, 5: 8 * time.Second} {
|
||||
if got := defaultNetworkRetryBackoff(attempt); got != want {
|
||||
t.Errorf("defaultNetworkRetryBackoff(%d) = %s, want %s", attempt, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withInstantBackoff zeros the retry backoff for the duration of a test.
|
||||
func withInstantBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := networkRetryBackoff
|
||||
networkRetryBackoff = func(int) time.Duration { return 0 }
|
||||
t.Cleanup(func() { networkRetryBackoff = prev })
|
||||
}
|
||||
|
||||
func TestRunRetriesTransientNetworkFailure(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventError, Error: "provider stream error: net/http: TLS handshake timeout"}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "built it"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
var retries []int
|
||||
result, err := Run(context.Background(), "build", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnNetworkRetry: func(attempt int, _ string) { retries = append(retries, attempt) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected the transient failure to be retried to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "built it" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "built it")
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider requests (initial + 1 retry), got %d", len(provider.requests))
|
||||
}
|
||||
if len(retries) != 1 || retries[0] != 1 {
|
||||
t.Fatalf("expected one OnNetworkRetry(attempt=1), got %#v", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryTerminalError(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventError, Error: "provider error: status code 401: invalid api key"}},
|
||||
},
|
||||
}
|
||||
_, err := Run(context.Background(), "build", provider, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("expected a terminal (auth) error to surface, not be retried")
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("a terminal error must not be retried, got %d requests", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryAfterPartialOutput(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
// Text was already streamed before the error → retrying would duplicate it.
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "partial answer"},
|
||||
{Type: zeroruntime.StreamEventError, Error: "net/http: TLS handshake timeout"},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := Run(context.Background(), "build", provider, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("expected the mid-stream error to surface (no retry after partial output)")
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("must not retry once output was produced, got %d requests", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryAfterReasoningOrDroppedOutput(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
// A stream can surface reasoning blocks or dropped tool-call signals without
|
||||
// any text/tool calls. Retrying would duplicate that surfaced output, so the
|
||||
// no-output guard must treat either as "produced output" and NOT retry.
|
||||
cases := map[string]zeroruntime.StreamEvent{
|
||||
"reasoning": {Type: zeroruntime.StreamEventReasoning, ReasoningBlocks: []zeroruntime.ReasoningBlock{{Provider: "anthropic", Type: "thinking", Text: "planning"}}},
|
||||
"dropped": {Type: zeroruntime.StreamEventToolCallDropped},
|
||||
}
|
||||
for name, output := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{output, {Type: zeroruntime.StreamEventError, Error: "net/http: TLS handshake timeout"}},
|
||||
},
|
||||
}
|
||||
_, err := Run(context.Background(), "build", provider, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("expected the error to surface (no retry after non-text output)")
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("must not retry once %s output was produced, got %d requests", name, len(provider.requests))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesInitialCallTransientFailure(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
// The FIRST StreamCompletion CALL fails transiently (connection error before
|
||||
// any stream), then succeeds. The call-level failure must be routed through
|
||||
// the same retry flow, not returned immediately.
|
||||
provider := &callErrorProvider{
|
||||
errCalls: 1,
|
||||
err: `Post "https://api.example.com/v1/chat": net/http: TLS handshake timeout`,
|
||||
ok: []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}
|
||||
var retries []int
|
||||
result, err := Run(context.Background(), "build", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnNetworkRetry: func(attempt int, _ string) { retries = append(retries, attempt) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("initial call-level transient failure should be retried to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if provider.calls != 2 {
|
||||
t.Fatalf("expected 2 calls (initial + 1 retry), got %d", provider.calls)
|
||||
}
|
||||
if len(retries) != 1 || retries[0] != 1 {
|
||||
t.Fatalf("expected one OnNetworkRetry(attempt=1), got %#v", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAnnotatesInitialCallAfterRetries(t *testing.T) {
|
||||
withInstantBackoff(t)
|
||||
// Every initial CALL fails transiently → the surfaced error is the actionable
|
||||
// connectivity message, and the call is attempted initial + maxNetworkRetries.
|
||||
provider := &callErrorProvider{errCalls: 9, err: "net/http: TLS handshake timeout"}
|
||||
_, err := Run(context.Background(), "build", provider, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error once every initial-call retry failed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/provider") || !strings.Contains(err.Error(), "network/connectivity") {
|
||||
t.Fatalf("error should be the actionable network message, got %q", err.Error())
|
||||
}
|
||||
if provider.calls != 3 { // initial + maxNetworkRetries(2)
|
||||
t.Fatalf("expected 3 calls, got %d", provider.calls)
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,10 @@ type Options struct {
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// OnNetworkRetry, when set, is called before each transparent retry of a
|
||||
// transient network failure (TLS/dial timeout, connection reset) so a surface
|
||||
// can show "retrying…". attempt is 1-based; reason is the (redacted) error.
|
||||
OnNetworkRetry func(attempt int, reason string)
|
||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||
// mid-run: the loop calls it with the requested model id and, on success,
|
||||
// swaps the active provider and updates Options.Model for the rest of the
|
||||
|
||||
@@ -102,11 +102,9 @@ var descriptors = []Descriptor{
|
||||
transportDescriptor("vertex", "Vertex AI", TransportVertex, "https://aiplatform.googleapis.com", "gemini-2.5-pro", []string{"GOOGLE_APPLICATION_CREDENTIALS"}, []APIFormat{APIFormatVertexGenerateContent}, true),
|
||||
oauthProvider(openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}), false, true),
|
||||
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
// the official client), so zero does not call it directly; instead point this
|
||||
|
||||
@@ -28,11 +28,9 @@ var expectedCatalogIDs = []string{
|
||||
"vertex",
|
||||
"xai",
|
||||
"venice",
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"gitlawb-opengateway",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
"custom-anthropic-compatible",
|
||||
@@ -172,7 +170,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"mini_max": "minimax",
|
||||
"Moonshot": "moonshot",
|
||||
"nvidia nim": "nvidia-nim",
|
||||
"xiaomi mimo": "xiaomi-mimo",
|
||||
"custom_openai_compatible": "custom-openai-compatible",
|
||||
"custom--anthropic compatible": "custom-anthropic-compatible",
|
||||
"GitLawb OpenGateway": "gitlawb-opengateway",
|
||||
@@ -221,7 +218,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "bankr", "zai", "gitlawb-opengateway", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -110,10 +110,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "llama-3.3-70b", Description: "general model"},
|
||||
{ID: "deepseek-r1-671b", Description: "reasoning model"},
|
||||
},
|
||||
"xiaomi-mimo": {
|
||||
{ID: "mimo-vl", Description: "catalog default"},
|
||||
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model"},
|
||||
},
|
||||
"bankr": {
|
||||
{ID: "bankr-large", Description: "catalog default"},
|
||||
},
|
||||
@@ -128,10 +124,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "claude-sonnet-4.5", Description: "coding model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model"},
|
||||
},
|
||||
"atomic-chat": {
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
{ID: "gpt-4o-mini", Description: "fast model"},
|
||||
},
|
||||
"custom-openai-compatible": {
|
||||
{ID: "custom-model", Description: "custom endpoint model"},
|
||||
},
|
||||
|
||||
@@ -126,8 +126,6 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
return "moonshotai"
|
||||
case "nvidia-nim":
|
||||
return "nvidia"
|
||||
case "xiaomi-mimo":
|
||||
return "xiaomi"
|
||||
default:
|
||||
return strings.TrimSpace(provider.ID)
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
"github": "github-models",
|
||||
"moonshot": "moonshotai",
|
||||
"nvidia-nim": "nvidia",
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"dashscope": "alibaba",
|
||||
"ollama-cloud": "ollama-cloud",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestScopedDestructivePromptsWhileCatastrophicDenies pins the split between a
|
||||
// scoped delete a user can approve and the irrecoverable system-level forms that
|
||||
// stay a hard block even in unsafe mode.
|
||||
func TestScopedDestructivePromptsWhileCatastrophicDenies(t *testing.T) {
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
|
||||
|
||||
shellReq := func(command string, mode PermissionMode) Request {
|
||||
return Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: SideEffectShell,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: mode,
|
||||
Autonomy: AutonomyHigh,
|
||||
Args: map[string]any{"command": command},
|
||||
}
|
||||
}
|
||||
|
||||
// rm -rf <subdir> in ask mode is now a prompt the user can approve, not a hard
|
||||
// block — and it must be "destructive" but NOT "destructive_catastrophic".
|
||||
scoped := engine.Evaluate(context.Background(), shellReq("rm -rf site", PermissionModeAsk))
|
||||
if scoped.Action != ActionPrompt {
|
||||
t.Fatalf("rm -rf <subdir> in ask mode = %#v, want ActionPrompt", scoped)
|
||||
}
|
||||
if !HasRiskCategory(scoped.Risk, "destructive") || HasRiskCategory(scoped.Risk, "destructive_catastrophic") {
|
||||
t.Fatalf("rm -rf <subdir> categories = %v, want destructive (not catastrophic)", scoped.Risk.Categories)
|
||||
}
|
||||
|
||||
// The same scoped delete is allowed in unsafe mode (the operator opted in).
|
||||
if d := engine.Evaluate(context.Background(), shellReq("rm -rf site", PermissionUnsafe)); d.Action != ActionAllow {
|
||||
t.Fatalf("rm -rf <subdir> in unsafe = %#v, want ActionAllow", d)
|
||||
}
|
||||
|
||||
// A nested relative path is still scoped, not catastrophic.
|
||||
if d := engine.Evaluate(context.Background(), shellReq("rm -rf build/output", PermissionModeAsk)); d.Action != ActionPrompt {
|
||||
t.Fatalf("rm -rf build/output in ask = %#v, want ActionPrompt", d)
|
||||
}
|
||||
|
||||
// A bare-glob delete (rm -rf *) is workspace-local: promptable in ask mode and
|
||||
// allowed once opted into via unsafe — NOT a hard-denied catastrophic command.
|
||||
// (Regression guard: it used to be over-tagged as destructive_catastrophic.)
|
||||
if d := engine.Evaluate(context.Background(), shellReq("rm -rf *", PermissionModeAsk)); d.Action != ActionPrompt {
|
||||
t.Fatalf("rm -rf * in ask = %#v, want ActionPrompt", d)
|
||||
}
|
||||
glob := engine.Evaluate(context.Background(), shellReq("rm -rf *", PermissionModeAsk))
|
||||
if !HasRiskCategory(glob.Risk, "destructive") || HasRiskCategory(glob.Risk, "destructive_catastrophic") {
|
||||
t.Fatalf("rm -rf * categories = %v, want destructive (not catastrophic)", glob.Risk.Categories)
|
||||
}
|
||||
if d := engine.Evaluate(context.Background(), shellReq("rm -rf *", PermissionUnsafe)); d.Action != ActionAllow {
|
||||
t.Fatalf("rm -rf * in unsafe = %#v, want ActionAllow", d)
|
||||
}
|
||||
|
||||
// Catastrophic system-level commands stay a hard block — even in unsafe mode.
|
||||
// The path-traversal escapes (rm -rf ../..) are the regression Vasanth flagged:
|
||||
// they used to fall through to allow in unsafe mode and delete outside the
|
||||
// workspace.
|
||||
catastrophic := []string{
|
||||
"rm -rf /",
|
||||
"rm -rf $HOME",
|
||||
"rm -rf ~",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
":(){ :|:& };:",
|
||||
"rm -rf ../../",
|
||||
"rm -rf ../../etc",
|
||||
"rm --no-preserve-root -rf ../../",
|
||||
}
|
||||
for _, command := range catastrophic {
|
||||
d := engine.Evaluate(context.Background(), shellReq(command, PermissionUnsafe))
|
||||
if d.Action != ActionDeny || d.Violation == nil || d.Violation.Code != ViolationDestructiveCommand {
|
||||
t.Fatalf("catastrophic %q in unsafe = %#v, want destructive deny", command, d)
|
||||
}
|
||||
if !HasRiskCategory(d.Risk, "destructive_catastrophic") {
|
||||
t.Fatalf("catastrophic %q categories = %v, want destructive_catastrophic", command, d.Risk.Categories)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +298,21 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
||||
return deny(request, risk, ViolationNetwork, "", "network access is blocked by sandbox policy", false)
|
||||
}
|
||||
if policy.DenyDestructiveShell && HasRiskCategory(risk, "destructive") {
|
||||
return deny(request, risk, ViolationDestructiveCommand, "", "destructive shell command is blocked by sandbox policy", false)
|
||||
granted := request.PermissionGranted || request.PermissionMode == PermissionUnsafe
|
||||
switch {
|
||||
case HasRiskCategory(risk, "destructive_catastrophic"):
|
||||
// Irrecoverable, system-level commands (rm -rf / or $HOME, mkfs, dd to a
|
||||
// raw device, fork bomb, …) are never run — not even in unsafe mode.
|
||||
return deny(request, risk, ViolationDestructiveCommand, "", "destructive shell command is blocked by sandbox policy", false)
|
||||
case granted:
|
||||
// Unsafe mode / an explicit grant: fall through to the normal allow path
|
||||
// below so a scoped delete the operator opted into actually runs.
|
||||
default:
|
||||
// Scoped-destructive (e.g. rm -rf <subdir>): ask the user instead of
|
||||
// hard-blocking, so a deletion they intend can be approved at the prompt
|
||||
// rather than forcing an ask_user workaround.
|
||||
return Decision{Action: ActionPrompt, Risk: risk, Reason: "destructive shell command needs confirmation"}
|
||||
}
|
||||
}
|
||||
if engine.store != nil {
|
||||
reqRaw, _ := DeriveScope(request.ToolName, request.Args)
|
||||
|
||||
@@ -48,6 +48,32 @@ var (
|
||||
// mkfs.<fstype> form (e.g. mkfs.ext4) not caught by the bare \bmkfs\b above when followed by a dot.
|
||||
regexp.MustCompile(`(?i)\bmkfs\.[a-z0-9]+\b`),
|
||||
}
|
||||
// catastrophicCommandPattern mirrors destructiveCommandPattern but restricts
|
||||
// the rm target to the IRRECOVERABLE / workspace-escaping forms — a system
|
||||
// root (/), $HOME (bare/braced), ~, or a path-traversal escape (..) — while
|
||||
// keeping the always-irrecoverable non-rm forms (mkfs, dd if=, recursive or
|
||||
// system chmod 777, chown -R). A bare-glob delete (rm -rf *) is deliberately
|
||||
// ABSENT: it is workspace-local and the engine downgrades it to a prompt
|
||||
// rather than hard-denying it. Everything this matches stays a hard block
|
||||
// even in unsafe mode.
|
||||
catastrophicCommandPattern = regexp.MustCompile(`(?i)(\brm\s+(-[A-Za-z]*r[A-Za-z]*f|-rf|-fr)\s+(--\s+)?["']?(\$\{?HOME\}?|/|~|\.\.)["']?|\bmkfs\b|\bdd\s+if=|\bchmod\s+(-[A-Za-z]*[rR][A-Za-z]*\s+)+0?777\b|\bchmod\s+(-\S+\s+)*0?777\s+-[A-Za-z]*[rR][A-Za-z]*\b|\bchmod\s+(-\S+\s+)*0?777\s+["']?/(\s|$|["']|(etc|usr|bin|sbin|lib|lib64|var|boot|opt|root|sys|proc|dev)\b)|\bchown\s+-R\b)`)
|
||||
// catastrophicExtraPatterns mirror destructiveExtraPatterns: the device/fork-
|
||||
// bomb forms are unconditionally catastrophic; the rm form drops the bare-glob
|
||||
// target (kept scoped) and adds a path-traversal target (..) so a workspace
|
||||
// escape stays denied. mkfs.<fstype> is likewise catastrophic.
|
||||
catastrophicExtraPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:`),
|
||||
regexp.MustCompile(`(?i)>\s*/dev/(sd[a-z]+\d*|nvme\d+n\d+(p\d+)?|hd[a-z]+\d*|xvd[a-z]+\d*|mmcblk\d+)`),
|
||||
regexp.MustCompile(`(?i)\bof=/dev/(sd[a-z]+\d*|nvme\d+n\d+(p\d+)?|hd[a-z]+\d*|xvd[a-z]+\d*|mmcblk\d+)`),
|
||||
regexp.MustCompile(`(?i)\brm\s+(-{1,2}\S+\s+)*(--\s+)?["']?(/\*?|~|\$\{?HOME\}?|\.\.)["']?(\s|$|/)`),
|
||||
regexp.MustCompile(`(?i)\bmkfs\.[a-z0-9]+\b`),
|
||||
}
|
||||
// pathTraversalPattern matches a `..` path component (../, /.., or a bare ..).
|
||||
// Combined with a destructive classification it flags a command whose target
|
||||
// escapes the workspace — fail-closed: a destructive command containing any
|
||||
// `..` component is treated as catastrophic even if it would resolve inside
|
||||
// the workspace, because the regex matchers cannot prove it does.
|
||||
pathTraversalPattern = regexp.MustCompile(`(^|[\s"'=:(/])\.\.(/|$|[\s"'])`)
|
||||
)
|
||||
|
||||
func matchesDestructive(command string) bool {
|
||||
@@ -62,6 +88,31 @@ func matchesDestructive(command string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesCatastrophic reports the irrecoverable / workspace-escaping subset of
|
||||
// matchesDestructive: the forms that stay a hard deny even in unsafe mode. It is
|
||||
// strictly narrower than matchesDestructive — a workspace-local delete such as
|
||||
// `rm -rf *` or `rm -rf <subdir>` is destructive but NOT catastrophic, so the
|
||||
// engine can downgrade it to a prompt instead of hard-blocking it.
|
||||
func matchesCatastrophic(command string) bool {
|
||||
if catastrophicCommandPattern.MatchString(command) {
|
||||
return true
|
||||
}
|
||||
for _, pattern := range catastrophicExtraPatterns {
|
||||
if pattern.MatchString(command) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// commandHasPathTraversal reports whether the command contains a `..` path
|
||||
// component. Used only in combination with a destructive classification to keep
|
||||
// a workspace-escaping delete (e.g. `rm -rf ../../`) catastrophic even when the
|
||||
// regex matchers — which key off system-root/$HOME/~ targets — do not match it.
|
||||
func commandHasPathTraversal(command string) bool {
|
||||
return pathTraversalPattern.MatchString(command)
|
||||
}
|
||||
|
||||
func Classify(request Request) Risk {
|
||||
return classifyWithScope(request, nil)
|
||||
}
|
||||
@@ -103,6 +154,15 @@ func classifyWithScope(request Request, scope *Scope) Risk {
|
||||
if matchesDestructive(command) {
|
||||
add("destructive", RiskCritical)
|
||||
}
|
||||
if matchesCatastrophic(command) {
|
||||
// destructive_catastrophic marks the irrecoverable, system-level or
|
||||
// workspace-escaping forms (rm -rf / or $HOME/~/.., mkfs, dd to a raw
|
||||
// device, fork bomb, chmod 777 on a system tree, chown -R). These stay a
|
||||
// hard block even in unsafe mode. A workspace-local destructive command
|
||||
// (rm -rf * or rm -rf <subdir>) is "destructive" but NOT catastrophic, so
|
||||
// the engine downgrades it to a prompt rather than hard-blocking it.
|
||||
add("destructive_catastrophic", RiskCritical)
|
||||
}
|
||||
if pipedInstallerPattern.MatchString(command) {
|
||||
add("piped_installer", RiskCritical)
|
||||
}
|
||||
@@ -118,6 +178,14 @@ func classifyWithScope(request Request, scope *Scope) Risk {
|
||||
}
|
||||
if analysis.Destructive {
|
||||
add("destructive", RiskCritical)
|
||||
// A destructive command whose target escapes the workspace via path
|
||||
// traversal (..) is catastrophic and must stay denied even in unsafe
|
||||
// mode. The regex matchers only catch system-root/$HOME/~ targets, so the
|
||||
// AST (which flags `rm -rf <anything>` regardless of flag order) plus a
|
||||
// simple `..` check closes the `rm -rf ../../` escape Vasanth flagged.
|
||||
if commandHasPathTraversal(command) {
|
||||
add("destructive_catastrophic", RiskCritical)
|
||||
}
|
||||
}
|
||||
if analysis.TooComplex {
|
||||
add("unparseable_command", RiskHigh)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// codePanelMaxLines caps how many diff rows the Code card shows; it keeps the
|
||||
// most recent window (the live tail of the edit) and notes how many earlier
|
||||
// rows were elided.
|
||||
const codePanelMaxLines = 14
|
||||
|
||||
// codePanelActive reports whether the floating Code card should be drawn: only
|
||||
// while a run is in flight (shared right-column gate) AND that run has produced
|
||||
// at least one file edit whose diff we can show live.
|
||||
func (m model) codePanelActive() bool {
|
||||
if !m.rightColumnBase() {
|
||||
return false
|
||||
}
|
||||
_, diff := m.currentEditDiff()
|
||||
return diff != ""
|
||||
}
|
||||
|
||||
// currentEditDiff returns the path and unified diff of the most recent file
|
||||
// edit in the active run, or ("", "") when the run has not edited anything yet.
|
||||
// It is the data behind the live Code card and the reason inline edit cards
|
||||
// collapse while a run is in flight (the full diff lives in the card instead).
|
||||
func (m model) currentEditDiff() (string, string) {
|
||||
if m.activeRunID == 0 {
|
||||
return "", ""
|
||||
}
|
||||
for index := len(m.transcript) - 1; index >= 0; index-- {
|
||||
row := m.transcript[index]
|
||||
if row.runID != m.activeRunID {
|
||||
continue
|
||||
}
|
||||
if row.kind != rowToolResult || !toolCardAlwaysExpands(row.tool) || row.status == tools.StatusError {
|
||||
continue
|
||||
}
|
||||
diff := strings.TrimSpace(row.detail)
|
||||
if diff == "" {
|
||||
continue
|
||||
}
|
||||
return diffPath(diff), diff
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// diffPath pulls the edited file's path out of a unified diff (the "+++ b/path"
|
||||
// line, falling back to the "diff --git" header), or "" when neither is present.
|
||||
func diffPath(diff string) string {
|
||||
lines := strings.Split(diff, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "+++ ") {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, "+++ "))
|
||||
path = strings.TrimPrefix(path, "b/")
|
||||
if path != "" && path != "/dev/null" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "diff --git ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
return strings.TrimPrefix(fields[len(fields)-1], "b/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// diffCounts tallies added/removed lines in a unified diff, ignoring the
|
||||
// +++/--- file headers so they are not miscounted as content.
|
||||
func diffCounts(diff string) (int, int) {
|
||||
adds, dels := 0, 0
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
|
||||
// File headers, not content.
|
||||
case strings.HasPrefix(line, "+"):
|
||||
adds++
|
||||
case strings.HasPrefix(line, "-"):
|
||||
dels++
|
||||
}
|
||||
}
|
||||
return adds, dels
|
||||
}
|
||||
|
||||
// renderCodeCard draws the live "Code" card: the edited file's path with its
|
||||
// +adds/−dels tally, then the diff body painted green (additions) / red
|
||||
// (removals) — the same palette as the inline diff cards, sized to the widget.
|
||||
func (m model) renderCodeCard(path, diff string) string {
|
||||
inner := planPanelWidth - 4
|
||||
|
||||
adds, dels := diffCounts(diff)
|
||||
head := zeroTheme.accent.Bold(true).Render("Code")
|
||||
if adds > 0 {
|
||||
head += " " + zeroTheme.diffAdd.Render(fmt.Sprintf("+%d", adds))
|
||||
}
|
||||
if dels > 0 {
|
||||
head += " " + zeroTheme.diffDel.Render(fmt.Sprintf("−%d", dels))
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
path = "current file"
|
||||
}
|
||||
body := []string{
|
||||
head,
|
||||
zeroTheme.faint.Render(cutRunesEllipsis(path, inner)),
|
||||
"",
|
||||
}
|
||||
body = append(body, codeDiffLines(diff, inner)...)
|
||||
return framePlanPanel(body)
|
||||
}
|
||||
|
||||
// codeDiffLines renders a unified diff as styled bands for the Code card: "+"
|
||||
// rows green, "−" rows red, hunk headers faint, context dimmed. The file-header
|
||||
// noise is dropped (the path is already in the card head). Only the most recent
|
||||
// codePanelMaxLines rows are kept so the card tracks the live tail of the edit.
|
||||
func codeDiffLines(diff string, inner int) []string {
|
||||
raw := strings.Split(diff, "\n")
|
||||
lines := make([]string, 0, len(raw))
|
||||
for _, line := range raw {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),
|
||||
strings.HasPrefix(line, "diff --git"), strings.HasPrefix(line, "index "):
|
||||
// File-header noise: the path is shown in the card head instead.
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
lines = append(lines, zeroTheme.diffMeta.Render(cutRunesEllipsis(line, inner)))
|
||||
case strings.HasPrefix(line, "+"):
|
||||
lines = append(lines, codeBand(zeroTheme.addLine, "+", strings.TrimPrefix(line, "+"), inner))
|
||||
case strings.HasPrefix(line, "-"):
|
||||
lines = append(lines, codeBand(zeroTheme.delLine, "−", strings.TrimPrefix(line, "-"), inner))
|
||||
default:
|
||||
text := strings.TrimPrefix(line, " ")
|
||||
lines = append(lines, zeroTheme.faint.Render(cutRunesEllipsis(" "+text, inner)))
|
||||
}
|
||||
}
|
||||
if len(lines) > codePanelMaxLines {
|
||||
hidden := len(lines) - codePanelMaxLines
|
||||
lines = lines[len(lines)-codePanelMaxLines:]
|
||||
header := zeroTheme.faint.Render(fmt.Sprintf("… %d earlier lines", hidden))
|
||||
lines = append([]string{header}, lines...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// codeBand paints one changed diff row as a solid colored band: "<sign> text"
|
||||
// padded to the full inner width so the add/del tint reads edge to edge.
|
||||
func codeBand(style lipgloss.Style, sign, text string, inner int) string {
|
||||
content := cutRunesEllipsis(sign+" "+text, inner)
|
||||
if pad := inner - lipgloss.Width(content); pad > 0 {
|
||||
content += strings.Repeat(" ", pad)
|
||||
}
|
||||
return style.Render(content)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const sampleEditDiff = `--- a/app.js
|
||||
+++ b/app.js
|
||||
@@ -1,3 +1,4 @@
|
||||
function addToCart(id) {
|
||||
- // placeholder
|
||||
+ cart.push(id)
|
||||
+ save()
|
||||
}`
|
||||
|
||||
// longEditDiff is a diff with more than cardBodyMaxLines content rows, so the
|
||||
// inline card collapses when the Code card is showing it.
|
||||
func longEditDiff() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("--- a/big.js\n+++ b/big.js\n@@ -1,1 +1,30 @@\n")
|
||||
for i := 0; i < 30; i++ {
|
||||
fmt.Fprintf(&b, "+line %d\n", i)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// editingRunModel returns an alt-screen model mid-run whose active run has
|
||||
// produced a file edit, so the Code card is active.
|
||||
func editingRunModel(t *testing.T, diff string) model {
|
||||
t.Helper()
|
||||
m := mouseTestModel() // alt-screen, width 100, height 30
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "edit_file", id: "e1", runID: 7,
|
||||
status: tools.StatusOK, detail: diff,
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestDiffPathAndCounts(t *testing.T) {
|
||||
if got := diffPath(sampleEditDiff); got != "app.js" {
|
||||
t.Fatalf("diffPath = %q, want app.js", got)
|
||||
}
|
||||
if got := diffPath("no headers here"); got != "" {
|
||||
t.Fatalf("diffPath with no headers = %q, want empty", got)
|
||||
}
|
||||
adds, dels := diffCounts(sampleEditDiff)
|
||||
if adds != 2 || dels != 1 {
|
||||
t.Fatalf("diffCounts = (+%d, −%d), want (+2, −1)", adds, dels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentEditDiffFindsLatestEditInActiveRun(t *testing.T) {
|
||||
m := editingRunModel(t, sampleEditDiff)
|
||||
path, diff := m.currentEditDiff()
|
||||
if path != "app.js" || diff == "" {
|
||||
t.Fatalf("currentEditDiff = (%q, %d bytes), want app.js with a diff", path, len(diff))
|
||||
}
|
||||
|
||||
// A later edit in the same run wins over the earlier one.
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "write_file", id: "e2", runID: 7,
|
||||
status: tools.StatusOK, detail: "--- a/style.css\n+++ b/style.css\n@@ -0,0 +1,1 @@\n+body{}",
|
||||
})
|
||||
if path, _ := m.currentEditDiff(); path != "style.css" {
|
||||
t.Fatalf("currentEditDiff after second edit = %q, want style.css", path)
|
||||
}
|
||||
|
||||
// Edits from another run, and failed edits, are ignored.
|
||||
other := editingRunModel(t, sampleEditDiff)
|
||||
other.activeRunID = 99 // the only edit row belongs to run 7
|
||||
if _, diff := other.currentEditDiff(); diff != "" {
|
||||
t.Fatal("an edit from a different run must not feed the Code card")
|
||||
}
|
||||
failed := editingRunModel(t, sampleEditDiff)
|
||||
failed.transcript[len(failed.transcript)-1].status = tools.StatusError
|
||||
if _, diff := failed.currentEditDiff(); diff != "" {
|
||||
t.Fatal("a failed edit must not feed the Code card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodePanelActiveGating(t *testing.T) {
|
||||
if !editingRunModel(t, sampleEditDiff).codePanelActive() {
|
||||
t.Fatal("Code card should show during a run that edited a file")
|
||||
}
|
||||
|
||||
done := editingRunModel(t, sampleEditDiff)
|
||||
done.pending = false
|
||||
if done.codePanelActive() {
|
||||
t.Fatal("Code card must hide once the run finishes")
|
||||
}
|
||||
|
||||
inline := editingRunModel(t, sampleEditDiff)
|
||||
inline.altScreen = false
|
||||
if inline.codePanelActive() {
|
||||
t.Fatal("no Code card in inline mode")
|
||||
}
|
||||
|
||||
noEdit := mouseTestModel()
|
||||
noEdit.pending = true
|
||||
noEdit.activeRunID = 3
|
||||
noEdit.transcript = appendTranscriptRow(noEdit.transcript, transcriptRow{kind: rowToolCall, tool: "bash", id: "b1", runID: 3})
|
||||
if noEdit.codePanelActive() {
|
||||
t.Fatal("no Code card for a run that has not edited anything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCodeCardShowsPathAndColoredDiff(t *testing.T) {
|
||||
m := editingRunModel(t, sampleEditDiff)
|
||||
plain := plainRender(t, m.renderCodeCard("app.js", sampleEditDiff))
|
||||
|
||||
for _, want := range []string{"Code", "app.js", "+2", "−1", "cart.push(id)", "placeholder", "╭", "╰"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("Code card missing %q, got:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightColumnStacksPlanThenCode(t *testing.T) {
|
||||
m := editingRunModel(t, sampleEditDiff)
|
||||
// Same run also produced a plan → both cards stack.
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowToolCall, tool: planToolName, id: "p1", runID: 7})
|
||||
|
||||
plain := plainRender(t, m.renderRightColumn())
|
||||
planAt := strings.Index(plain, "Plan")
|
||||
codeAt := strings.Index(plain, "Code")
|
||||
if planAt < 0 || codeAt < 0 {
|
||||
t.Fatalf("right column should carry both cards, got:\n%s", plain)
|
||||
}
|
||||
if planAt > codeAt {
|
||||
t.Fatal("Plan card should stack above the Code card")
|
||||
}
|
||||
|
||||
// Idle model → empty column.
|
||||
if got := mouseTestModel().renderRightColumn(); got != "" {
|
||||
t.Fatalf("idle right column = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightColumnNeverBuriesTheWholeChat(t *testing.T) {
|
||||
m := editingRunModel(t, longEditDiff())
|
||||
// A long plan on top of the long diff makes the column taller than the chat.
|
||||
for i := 0; i < 14; i++ {
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowToolCall, tool: planToolName, id: fmt.Sprintf("p%d", i), runID: 7})
|
||||
}
|
||||
m.height = 24
|
||||
|
||||
rows := make([]string, m.height)
|
||||
for i := range rows {
|
||||
rows[i] = fmt.Sprintf("row%d", i)
|
||||
}
|
||||
out := strings.Split(m.composeWithPlanPanel(strings.Join(rows, "\n")), "\n")
|
||||
|
||||
// The last planPanelReserveRows lines must remain untouched transcript content
|
||||
// (no widget border overlaid), so the composer/newest lines stay visible.
|
||||
for i := len(out) - planPanelReserveRows; i < len(out); i++ {
|
||||
if strings.ContainsAny(out[i], "│╭╰") {
|
||||
t.Fatalf("reserved bottom row %d was covered by the column: %q", i, out[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditCardsCollapseOnlyWhileCodePanelActive(t *testing.T) {
|
||||
diff := longEditDiff()
|
||||
m := editingRunModel(t, diff)
|
||||
m.transcript[len(m.transcript)-1].detail = diff // the active edit is the long one
|
||||
row := m.transcript[len(m.transcript)-1]
|
||||
rc := buildRowContext(m.transcript)
|
||||
|
||||
// Code card active → the inline edit card collapses to a one-line record.
|
||||
live := plainRender(t, m.renderRow(row, 80, rc))
|
||||
if !strings.Contains(live, "click to expand") {
|
||||
t.Fatalf("edit card should collapse while the Code card is active, got:\n%s", live)
|
||||
}
|
||||
if strings.Contains(live, "line 5") {
|
||||
t.Fatal("collapsed edit card must not show the diff body in the chat")
|
||||
}
|
||||
|
||||
// Run finished (Code card gone) → the diff returns to the chat inline.
|
||||
done := m
|
||||
done.pending = false
|
||||
full := plainRender(t, done.renderRow(row, 80, buildRowContext(done.transcript)))
|
||||
if !strings.Contains(full, "line 5") {
|
||||
t.Fatalf("once the run ends the diff should render inline, got:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortEditDiffCollapsesInCompactMode(t *testing.T) {
|
||||
// A short edit diff (< cardBodyMaxLines) would normally render inline. But while
|
||||
// the live Code card shows the same diff, leaving the inline card expanded
|
||||
// duplicates it — so in compact-edit mode even a short edit collapses.
|
||||
m := editingRunModel(t, sampleEditDiff)
|
||||
row := m.transcript[len(m.transcript)-1]
|
||||
rc := buildRowContext(m.transcript)
|
||||
|
||||
live := plainRender(t, m.renderRow(row, 80, rc))
|
||||
if !strings.Contains(live, "click to expand") {
|
||||
t.Fatalf("short edit card should collapse in compact-edit mode, got:\n%s", live)
|
||||
}
|
||||
if strings.Contains(live, "cart.push(id)") {
|
||||
t.Fatal("collapsed short edit card must not duplicate the diff body in the chat")
|
||||
}
|
||||
|
||||
// Run finished (Code card gone) → the short diff renders inline again.
|
||||
done := m
|
||||
done.pending = false
|
||||
full := plainRender(t, done.renderRow(row, 80, buildRowContext(done.transcript)))
|
||||
if !strings.Contains(full, "cart.push(id)") {
|
||||
t.Fatalf("once the run ends the short diff should render inline, got:\n%s", full)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (m model) toolsText() string {
|
||||
func (m *model) mcpText() string {
|
||||
width := 0
|
||||
if m.width > 0 {
|
||||
width = chatWidth(m.width)
|
||||
width = m.chatAreaWidth()
|
||||
}
|
||||
return renderMCPView(m.mcpViewState(), width)
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ func (m model) replaceComposerRangeWithPastePreviews(state composerState, start
|
||||
}
|
||||
|
||||
func (m model) composerPastePreviewWrapWidth() int {
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
if width < 8 {
|
||||
width = defaultStartupWidth
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (m model) settleTranscript() (model, tea.Cmd) {
|
||||
m.flushed = len(m.transcript)
|
||||
}
|
||||
rc := buildRowContext(m.transcript)
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
batch := []string{}
|
||||
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
for m.flushed < len(m.transcript) {
|
||||
|
||||
+24
-8
@@ -107,11 +107,14 @@ type model struct {
|
||||
workingVerb *workingWords
|
||||
workingVerbTicks int
|
||||
pending bool
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// runStartedAt stamps when the current run began; the plan panel shows the
|
||||
// elapsed time off it. Zero when idle.
|
||||
runStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -1227,12 +1230,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
content = m.setupView(m.chatAreaWidth())
|
||||
} else if m.transcriptDetailed {
|
||||
content = m.detailedTranscriptView()
|
||||
} else {
|
||||
content = m.transcriptView()
|
||||
}
|
||||
// Dock the live plan/progress panel on the right (no-op when inactive).
|
||||
content = m.composeWithPlanPanel(content)
|
||||
|
||||
view := tea.NewView(content)
|
||||
view.AltScreen = m.altScreen
|
||||
@@ -1259,7 +1264,7 @@ func (m model) transcriptEmpty() bool {
|
||||
// the managed conversation view. Streaming/modal blocks and composer chrome are
|
||||
// always rendered here.
|
||||
func (m model) transcriptView() string {
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
providerOverlay := m.providerWizardOverlay(width)
|
||||
@@ -1880,7 +1885,7 @@ func (m model) moveComposerVisualCursor(direction int) (model, bool) {
|
||||
if direction == 0 {
|
||||
return m, false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
if width < 8 {
|
||||
return m, false
|
||||
}
|
||||
@@ -2458,6 +2463,7 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
|
||||
// countdown from the previous run.
|
||||
m.workingVerbTicks = 0
|
||||
m.workingVerb.Reset()
|
||||
m.runStartedAt = m.now()
|
||||
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, turnImages), m.spinner.Tick)
|
||||
}
|
||||
|
||||
@@ -2683,6 +2689,16 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
onText(delta)
|
||||
}
|
||||
}
|
||||
onNetworkRetry := options.OnNetworkRetry
|
||||
options.OnNetworkRetry = func(attempt int, reason string) {
|
||||
// Surface the transparent network retry so the backoff isn't a silent
|
||||
// pause. Only the attempt number is shown (the reason is already
|
||||
// redacted upstream, but the URL adds nothing useful here).
|
||||
m.sendAgentRow(runID, transcriptRow{kind: rowSystem, text: fmt.Sprintf("network issue reaching the provider — retrying (attempt %d)…", attempt)})
|
||||
if onNetworkRetry != nil {
|
||||
onNetworkRetry(attempt, reason)
|
||||
}
|
||||
}
|
||||
onPermissionRequest := options.OnPermissionRequest
|
||||
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
if onPermissionRequest != nil {
|
||||
|
||||
@@ -120,6 +120,7 @@ func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) {
|
||||
})
|
||||
base := time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC)
|
||||
times := []time.Time{
|
||||
base, // launchPrompt stamps m.runStartedAt (for the plan panel's elapsed)
|
||||
base,
|
||||
base.Add(1 * time.Second),
|
||||
base.Add(1800 * time.Millisecond),
|
||||
|
||||
@@ -335,7 +335,7 @@ func (m *model) selectSetupProviderAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
if len(m.setup.providers) == 0 {
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
height := normalizedStartupHeight(m.height)
|
||||
rowWidth := setupProviderBlockWidth(width, m.setup.providers)
|
||||
if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) {
|
||||
@@ -374,7 +374,7 @@ func (m *model) selectSetupModelAtMouse(msg tea.MouseMsg) (mouseSelectionTarget,
|
||||
if len(models) == 0 {
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
height := normalizedStartupHeight(m.height)
|
||||
rowWidth := setupModelBlockWidth(width, m.setup.models)
|
||||
if !setupBlockContainsMouseX(mouseX(msg), width, rowWidth) {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
// planPanelWidth is the column width of the floating plan widget.
|
||||
planPanelWidth = 40
|
||||
// planPanelMinChat is the narrowest the chat may stay beside the widget;
|
||||
// below it the widget is hidden so a small terminal isn't covered.
|
||||
planPanelMinChat = 48
|
||||
// planPanelMaxItems caps how many plan rows the widget lists before it
|
||||
// summarizes the remainder.
|
||||
planPanelMaxItems = 14
|
||||
// planPanelReserveRows is how many bottom chat rows (newest content + the
|
||||
// composer) the floating right column must never cover, so a tall column
|
||||
// cannot bury the whole conversation on a short terminal.
|
||||
planPanelReserveRows = 5
|
||||
// planToolName is the tool whose call marks a run as having a plan.
|
||||
planToolName = "update_plan"
|
||||
)
|
||||
|
||||
// rightColumnBase is the shared visibility gate for the floating right column
|
||||
// (the Plan card stacked over the Code card). It shows ONLY in full-screen mode
|
||||
// while a run is in flight, and never steals so much width that the chat becomes
|
||||
// unreadable. The individual cards add their own "is there anything to show"
|
||||
// test on top (a live plan, a live edit) so a trivial "hi" shows nothing.
|
||||
func (m model) rightColumnBase() bool {
|
||||
// Require at least one drawable row ABOVE the bottom reserve: composeWithPlanPanel
|
||||
// overlays only m.height-planPanelReserveRows rows, so on a short terminal
|
||||
// (height <= planPanelReserveRows) it would draw zero rows while activation still
|
||||
// reported visible. Gate by the same capacity so the two never diverge.
|
||||
if !m.altScreen || m.height <= planPanelReserveRows || m.setup.visible || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
if !m.pending {
|
||||
return false
|
||||
}
|
||||
if chatWidth(m.width)-planPanelWidth < planPanelMinChat {
|
||||
return false // keep the chat readable on small terminals
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// planPanelActive reports whether the floating plan widget should be drawn. It
|
||||
// shows ONLY while a run is in flight AND that run has actually produced a plan
|
||||
// (called update_plan) — so a trivial "hi" never shows a stale plan from an
|
||||
// earlier task, and the widget disappears the moment the run finishes.
|
||||
func (m model) planPanelActive() bool {
|
||||
return m.rightColumnBase() && m.currentRunHasPlan()
|
||||
}
|
||||
|
||||
// chatAreaWidth is the chat content width. The plan widget FLOATS over the
|
||||
// top-right corner rather than reserving a column, so this is simply the full
|
||||
// chat width — the renderers and mouse hit-tests are unchanged by the widget.
|
||||
func (m model) chatAreaWidth() int {
|
||||
return chatWidth(m.width)
|
||||
}
|
||||
|
||||
// currentRunHasPlan reports whether the active run called update_plan (so the
|
||||
// live plan belongs to what the agent is doing right now, not a previous task).
|
||||
func (m model) currentRunHasPlan() bool {
|
||||
if m.activeRunID == 0 {
|
||||
return false
|
||||
}
|
||||
for _, row := range m.transcript {
|
||||
if row.runID == m.activeRunID && row.kind == rowToolCall && row.tool == planToolName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// currentPlanItems returns the live update_plan steps, or nil.
|
||||
func (m model) currentPlanItems() []tools.PlanItem {
|
||||
if m.registry == nil {
|
||||
return nil
|
||||
}
|
||||
tool, ok := m.registry.Get(planToolName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
reader, ok := tool.(currentPlanReader)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return reader.CurrentPlan()
|
||||
}
|
||||
|
||||
// runningToolName returns the name of the most recent tool call that has not yet
|
||||
// produced a result (the one currently executing), or "".
|
||||
func (m model) runningToolName() string {
|
||||
rc := buildRowContext(m.transcript)
|
||||
for index := len(m.transcript) - 1; index >= 0; index-- {
|
||||
row := m.transcript[index]
|
||||
if row.kind == rowToolCall && row.id != "" && !rc.resolved[rcKey(row.runID, row.id)] {
|
||||
return row.tool
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// planActivityLabel is the human word for what the agent is doing right now.
|
||||
// Empty when no run is in flight.
|
||||
func (m model) planActivityLabel() string {
|
||||
if !m.pending {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(m.streamingText) != "" {
|
||||
return "Responding"
|
||||
}
|
||||
switch toolActivityKind(m.runningToolName()) {
|
||||
case "plan":
|
||||
return "Planning"
|
||||
case "build":
|
||||
return "Building"
|
||||
case "scan":
|
||||
return "Scanning"
|
||||
case "shell":
|
||||
return "Running"
|
||||
case "search":
|
||||
return "Searching"
|
||||
default:
|
||||
return "Thinking"
|
||||
}
|
||||
}
|
||||
|
||||
// toolActivityKind buckets a tool name into a coarse activity for the label.
|
||||
func toolActivityKind(tool string) string {
|
||||
switch tool {
|
||||
case "update_plan":
|
||||
return "plan"
|
||||
case "write_file", "edit_file", "apply_patch", "create_file", "str_replace", "multi_edit":
|
||||
return "build"
|
||||
case "read_file", "list_dir", "grep", "glob", "ripgrep", "search_files", "list_files":
|
||||
return "scan"
|
||||
case "bash", "shell", "run", "exec":
|
||||
return "shell"
|
||||
case "web_search", "web_fetch", "fetch", "search":
|
||||
return "search"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// planStatusGlyph maps a plan step's status to a glyph + style.
|
||||
func planStatusGlyph(status string) (string, lipgloss.Style) {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "in_progress", "in-progress", "active", "doing":
|
||||
return "◐", zeroTheme.accent
|
||||
case "done", "completed", "complete":
|
||||
return "✓", zeroTheme.accent
|
||||
case "blocked", "failed", "error":
|
||||
return "✗", zeroTheme.red
|
||||
default: // pending / todo / ""
|
||||
return "○", zeroTheme.faint
|
||||
}
|
||||
}
|
||||
|
||||
func formatPanelElapsed(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
total := int(d.Seconds())
|
||||
return fmt.Sprintf("%d:%02d", total/60, total%60)
|
||||
}
|
||||
|
||||
// cutRunesEllipsis trims to limit runes, ending in "…" when it had to cut, so a
|
||||
// long plan step reads as truncated rather than a hard break mid-word.
|
||||
func cutRunesEllipsis(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit == 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(runes[:limit-1]) + "…"
|
||||
}
|
||||
|
||||
// renderPlanPanel draws the COMPACT widget: a "Plan" title, the live activity
|
||||
// line (spinner + word + elapsed), then the plan steps with status glyphs — in a
|
||||
// small box sized to its content (never the full terminal height).
|
||||
func (m model) renderPlanPanel() string {
|
||||
inner := planPanelWidth - 4 // "│ " prefix + " │" suffix
|
||||
|
||||
body := []string{zeroTheme.accent.Bold(true).Render("Plan")}
|
||||
if label := m.planActivityLabel(); label != "" {
|
||||
status := strings.TrimSpace(m.spinner.View()) + " " + label
|
||||
if !m.runStartedAt.IsZero() {
|
||||
status += " " + formatPanelElapsed(m.now().Sub(m.runStartedAt))
|
||||
}
|
||||
body = append(body, zeroTheme.faint.Render(cutRunesEllipsis(status, inner)))
|
||||
}
|
||||
body = append(body, "")
|
||||
|
||||
items := m.currentPlanItems()
|
||||
for index, item := range items {
|
||||
if index >= planPanelMaxItems {
|
||||
body = append(body, zeroTheme.faint.Render(fmt.Sprintf("… %d more", len(items)-planPanelMaxItems)))
|
||||
break
|
||||
}
|
||||
glyph, style := planStatusGlyph(item.Status)
|
||||
text := cutRunesEllipsis(fmt.Sprintf("%d %s", index+1, item.Content), inner-2)
|
||||
body = append(body, style.Render(glyph)+" "+zeroTheme.ink.Render(text))
|
||||
}
|
||||
return framePlanPanel(body)
|
||||
}
|
||||
|
||||
// framePlanPanel boxes body into a compact widget exactly len(body)+2 lines tall
|
||||
// and planPanelWidth wide.
|
||||
func framePlanPanel(body []string) string {
|
||||
border := zeroTheme.faint
|
||||
inner := planPanelWidth - 4
|
||||
rule := strings.Repeat("─", planPanelWidth-2)
|
||||
lines := make([]string, 0, len(body)+2)
|
||||
lines = append(lines, border.Render("╭"+rule+"╮"))
|
||||
for _, row := range body {
|
||||
fitted := fitStyledLine(row, inner)
|
||||
pad := maxInt(0, inner-lipgloss.Width(fitted))
|
||||
lines = append(lines, border.Render("│ ")+fitted+strings.Repeat(" ", pad)+border.Render(" │"))
|
||||
}
|
||||
lines = append(lines, border.Render("╰"+rule+"╯"))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderRightColumn builds the floating right column for the active run: the
|
||||
// Plan card (when the run has a plan) stacked over the Code card (when the run
|
||||
// has edited a file). Returns "" when neither card has anything to show, which
|
||||
// is what keeps the column absent for trivial, non-planning, non-editing turns.
|
||||
func (m model) renderRightColumn() string {
|
||||
if !m.rightColumnBase() {
|
||||
return ""
|
||||
}
|
||||
cards := make([]string, 0, 2)
|
||||
if m.currentRunHasPlan() {
|
||||
cards = append(cards, m.renderPlanPanel())
|
||||
}
|
||||
if path, diff := m.currentEditDiff(); diff != "" {
|
||||
cards = append(cards, m.renderCodeCard(path, diff))
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(cards, "\n")
|
||||
}
|
||||
|
||||
// composeWithPlanPanel floats the right column over the TOP-RIGHT corner of the
|
||||
// rendered chat: only the first len(column) rows are overlaid, on their rightmost
|
||||
// planPanelWidth columns, leaving the transcript full-width everywhere else. A
|
||||
// no-op when the column is empty. (Name kept for the View() call site; it now
|
||||
// carries both the Plan card and the live Code card.)
|
||||
func (m model) composeWithPlanPanel(content string) string {
|
||||
column := m.renderRightColumn()
|
||||
if column == "" {
|
||||
return content
|
||||
}
|
||||
widget := strings.Split(column, "\n")
|
||||
if len(widget) == 0 {
|
||||
return content
|
||||
}
|
||||
fullWidth := chatWidth(m.width)
|
||||
leftWidth := fullWidth - planPanelWidth
|
||||
if leftWidth < planPanelMinChat {
|
||||
return content
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
// The column floats over the OLDER (top) rows only: always leave the most
|
||||
// recent chat lines and the composer visible at the bottom, so a tall column
|
||||
// (long plan + long diff) can never bury the whole conversation.
|
||||
overlayRows := len(widget)
|
||||
if m.height > 0 {
|
||||
if maxRows := m.height - planPanelReserveRows; overlayRows > maxRows {
|
||||
overlayRows = maxRows
|
||||
}
|
||||
}
|
||||
for index := 0; index < overlayRows && index < len(lines); index++ {
|
||||
left := fitStyledLine(lines[index], leftWidth)
|
||||
left += strings.Repeat(" ", maxInt(0, leftWidth-lipgloss.Width(left)))
|
||||
lines[index] = left + widget[index]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// plannedRunModel returns an alt-screen model mid-run whose active run has called
|
||||
// update_plan (so the plan widget is active).
|
||||
func plannedRunModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := mouseTestModel() // alt-screen, width 100, height 30
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowToolCall, tool: planToolName, id: "p1", runID: 7})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestPlanPanelShowsOnlyDuringAPlannedRun(t *testing.T) {
|
||||
// Idle, no plan → hidden.
|
||||
if mouseTestModel().planPanelActive() {
|
||||
t.Fatal("panel must be hidden when idle")
|
||||
}
|
||||
|
||||
// Pending but the run never called update_plan (e.g. "hi", or a delete task) →
|
||||
// hidden, even if an OLD plan still lingers in the tool's state.
|
||||
trivial := mouseTestModel()
|
||||
trivial.pending = true
|
||||
trivial.activeRunID = 9
|
||||
trivial.transcript = appendTranscriptRow(trivial.transcript, transcriptRow{kind: rowToolCall, tool: "bash", id: "b1", runID: 9})
|
||||
if trivial.planPanelActive() {
|
||||
t.Fatal("panel must stay hidden for a run that produced no plan")
|
||||
}
|
||||
|
||||
// Pending and this run called update_plan → shown.
|
||||
if !plannedRunModel(t).planPanelActive() {
|
||||
t.Fatal("panel should show during a run that called update_plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelHiddenWhenTerminalTooShort(t *testing.T) {
|
||||
// height <= planPanelReserveRows leaves zero drawable rows above the bottom
|
||||
// reserve, so composeWithPlanPanel overlays nothing. Activation must agree.
|
||||
short := plannedRunModel(t)
|
||||
short.height = planPanelReserveRows
|
||||
if short.rightColumnBase() {
|
||||
t.Fatal("right column must hide when height <= planPanelReserveRows")
|
||||
}
|
||||
if short.planPanelActive() {
|
||||
t.Fatal("plan panel must hide when height <= planPanelReserveRows")
|
||||
}
|
||||
// One more row → there is capacity for an overlay, so it shows again.
|
||||
short.height = planPanelReserveRows + 1
|
||||
if !short.rightColumnBase() {
|
||||
t.Fatal("right column should show once a drawable row exists above the reserve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelHiddenWhenNotApplicable(t *testing.T) {
|
||||
narrow := plannedRunModel(t)
|
||||
narrow.width = 60 // 60 - 40 < 48 → keep chat readable
|
||||
if narrow.planPanelActive() {
|
||||
t.Fatal("panel must hide on a narrow terminal")
|
||||
}
|
||||
|
||||
done := plannedRunModel(t)
|
||||
done.pending = false
|
||||
if done.planPanelActive() {
|
||||
t.Fatal("panel must hide once the run finishes (not pending)")
|
||||
}
|
||||
|
||||
inSetup := plannedRunModel(t)
|
||||
inSetup.setup.visible = true
|
||||
if inSetup.planPanelActive() {
|
||||
t.Fatal("no panel during setup")
|
||||
}
|
||||
|
||||
inline := plannedRunModel(t)
|
||||
inline.altScreen = false
|
||||
if inline.planPanelActive() {
|
||||
t.Fatal("no panel in inline mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeWithPlanPanelFloatsTopRightFullWidth(t *testing.T) {
|
||||
m := plannedRunModel(t)
|
||||
content := strings.Join([]string{"row0", "row1", "row2", "row3", "row4", "row5", "row6", "row7", "row8", "row9"}, "\n")
|
||||
|
||||
out := m.composeWithPlanPanel(content)
|
||||
lines := strings.Split(out, "\n")
|
||||
plain := plainRender(t, out)
|
||||
|
||||
if !strings.Contains(plain, "Plan") || !strings.Contains(plain, "╭") || !strings.Contains(plain, "╰") {
|
||||
t.Fatalf("expected the floating plan widget, got:\n%s", plain)
|
||||
}
|
||||
// The widget is compact (well under the content height); only its rows are
|
||||
// overlaid (full width = chat + widget), and the rows below stay untouched.
|
||||
full := chatWidth(m.width)
|
||||
widgetHeight := len(strings.Split(m.renderPlanPanel(), "\n"))
|
||||
if widgetHeight >= len(lines) {
|
||||
t.Fatalf("widget height %d should be compact (under content height)", widgetHeight)
|
||||
}
|
||||
for index := 0; index < widgetHeight; index++ {
|
||||
if w := lipgloss.Width(lines[index]); w != full {
|
||||
t.Fatalf("overlaid line %d width = %d, want full chat width %d", index, w, full)
|
||||
}
|
||||
}
|
||||
if got := plainRender(t, lines[len(lines)-1]); got != "row9" {
|
||||
t.Fatalf("last row should be untouched transcript content, got %q", got)
|
||||
}
|
||||
|
||||
// Inactive → no-op.
|
||||
if got := mouseTestModel().composeWithPlanPanel(content); got != content {
|
||||
t.Fatal("an inactive widget must not alter the content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanActivityLabel(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
if m.planActivityLabel() != "" {
|
||||
t.Fatal("no activity label when idle")
|
||||
}
|
||||
m.pending = true
|
||||
if got := m.planActivityLabel(); got != "Thinking" {
|
||||
t.Fatalf("pending with no tool = %q, want Thinking", got)
|
||||
}
|
||||
m.streamingText = "writing the answer"
|
||||
if got := m.planActivityLabel(); got != "Responding" {
|
||||
t.Fatalf("streaming = %q, want Responding", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanActivityFromRunningTool(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.pending = true
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowToolCall, id: "c1", tool: "write_file", runID: 1})
|
||||
if got := m.planActivityLabel(); got != "Building" {
|
||||
t.Fatalf("running write_file = %q, want Building", got)
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowToolResult, id: "c1", tool: "write_file", runID: 1})
|
||||
if got := m.planActivityLabel(); got != "Thinking" {
|
||||
t.Fatalf("resolved tool = %q, want Thinking", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolActivityKindAndStatusGlyphs(t *testing.T) {
|
||||
for tool, want := range map[string]string{
|
||||
"update_plan": "plan", "write_file": "build", "edit_file": "build",
|
||||
"read_file": "scan", "grep": "scan", "bash": "shell", "web_search": "search", "mystery": "",
|
||||
} {
|
||||
if got := toolActivityKind(tool); got != want {
|
||||
t.Fatalf("toolActivityKind(%q) = %q, want %q", tool, got, want)
|
||||
}
|
||||
}
|
||||
for status, want := range map[string]string{"in_progress": "◐", "done": "✓", "pending": "○", "": "○", "failed": "✗"} {
|
||||
if got, _ := planStatusGlyph(status); got != want {
|
||||
t.Fatalf("planStatusGlyph(%q) = %q, want %q", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutRunesEllipsis(t *testing.T) {
|
||||
if got := cutRunesEllipsis("short", 10); got != "short" {
|
||||
t.Fatalf("no-truncation = %q", got)
|
||||
}
|
||||
if got := cutRunesEllipsis("truncate me here", 8); got != "truncat…" {
|
||||
t.Fatalf("truncation = %q, want %q", got, "truncat…")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPanelElapsed(t *testing.T) {
|
||||
for d, want := range map[time.Duration]string{0: "0:00", 12 * time.Second: "0:12", 83 * time.Second: "1:23", 605 * time.Second: "10:05"} {
|
||||
if got := formatPanelElapsed(d); got != want {
|
||||
t.Fatalf("formatPanelElapsed(%s) = %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdatePlanRowsSuppressedFromChat(t *testing.T) {
|
||||
rc := buildRowContext(nil)
|
||||
|
||||
if !rc.skip(transcriptRow{kind: rowToolCall, tool: planToolName, id: "p1"}) {
|
||||
t.Fatal("update_plan tool call should be skipped from the chat (it lives in the plan panel / /plan)")
|
||||
}
|
||||
if !rc.skip(transcriptRow{kind: rowToolResult, tool: planToolName, id: "p1"}) {
|
||||
t.Fatal("update_plan's Current Plan result should be skipped from the chat")
|
||||
}
|
||||
// Ordinary tool rows still render.
|
||||
if rc.skip(transcriptRow{kind: rowToolResult, tool: "read_file", id: "r1"}) {
|
||||
t.Fatal("a normal tool result must still render in the chat")
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,12 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
|
||||
if m.pending && row.runID != 0 && row.runID == m.activeRunID {
|
||||
stable = false
|
||||
}
|
||||
case rowToolResult:
|
||||
// A diff card in the active run flips between full and collapsed as the
|
||||
// live Code card turns on/off, so it must not be served stale.
|
||||
if toolCardAlwaysExpands(row.tool) && m.pending && row.runID != 0 && row.runID == m.activeRunID {
|
||||
stable = false
|
||||
}
|
||||
case rowPermission:
|
||||
event := row.permission
|
||||
if event != nil && event.ToolCallID != "" && event.Action == agent.PermissionActionPrompt &&
|
||||
@@ -137,6 +143,7 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
|
||||
appendRenderCacheField(&b, strconv.FormatBool(flush))
|
||||
appendRenderCacheField(&b, strconv.Itoa(opts.bodyCap))
|
||||
appendRenderCacheField(&b, opts.cwd)
|
||||
appendRenderCacheField(&b, strconv.FormatBool(opts.compactEdit))
|
||||
appendRenderCacheField(&b, strconv.Itoa(int(row.kind)))
|
||||
appendRenderCacheField(&b, row.id)
|
||||
appendRenderCacheField(&b, row.text)
|
||||
|
||||
@@ -125,6 +125,13 @@ func buildRowContext(rows []transcriptRow) rowContext {
|
||||
// decided collapses into its decision line; an unprompted allow is already
|
||||
// surfaced as the card's [auto] tag.
|
||||
func (rc rowContext) skip(row transcriptRow) bool {
|
||||
// The plan is surfaced live in the right-side plan panel (and on demand via
|
||||
// /plan), so update_plan's tool call and its "Current Plan: …" result are NOT
|
||||
// echoed inline — that keeps the chat focused on the actual work instead of
|
||||
// repeating the whole plan on every revision.
|
||||
if row.tool == planToolName && (row.kind == rowToolCall || row.kind == rowToolResult) {
|
||||
return true
|
||||
}
|
||||
switch row.kind {
|
||||
case rowToolCall:
|
||||
return row.id != "" && rc.resolved[rcKey(row.runID, row.id)]
|
||||
@@ -145,11 +152,14 @@ func (rc rowContext) skip(row transcriptRow) bool {
|
||||
}
|
||||
|
||||
// cardRenderOptions carries per-render knobs for tool cards: the body-line cap
|
||||
// (small for the live region, generous for the permanent scrollback flush) and
|
||||
// the workspace root used to absolutize paths for OSC 8 file hyperlinks.
|
||||
// (small for the live region, generous for the permanent scrollback flush), the
|
||||
// workspace root used to absolutize paths for OSC 8 file hyperlinks, and whether
|
||||
// edit diffs should collapse to a one-line record because the live Code card is
|
||||
// already showing them (compactEdit; live region only, never the flush/detail).
|
||||
type cardRenderOptions struct {
|
||||
bodyCap int
|
||||
cwd string
|
||||
bodyCap int
|
||||
cwd string
|
||||
compactEdit bool
|
||||
}
|
||||
|
||||
// flushCardBodyMaxLines is the body cap for cards flushed to scrollback. The
|
||||
@@ -182,6 +192,13 @@ func (m model) renderRowMode(row transcriptRow, width int, rc rowContext, flush
|
||||
if flush {
|
||||
opts.bodyCap = flushCardBodyMaxLines
|
||||
}
|
||||
// In the live full-screen region, while the Code card is showing the active
|
||||
// run's diff, collapse inline edit cards to a one-line record so the diff is
|
||||
// not duplicated in the chat. Never in the flush (scrollback) or detailed
|
||||
// paths — those keep the full diff for review.
|
||||
if !flush && m.codePanelActive() {
|
||||
opts.compactEdit = true
|
||||
}
|
||||
if defaultRenderCache != nil {
|
||||
if key, stable := m.renderRowCacheKey(row, width, rc, opts, flush); key != "" {
|
||||
return defaultRenderCache.render(key, stable, func() string {
|
||||
@@ -946,8 +963,12 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
|
||||
// scrollback clean. Skipped for: the uncapped detailed view (opts.bodyCap==0),
|
||||
// diff tools whose body must stay reviewable, and short output.
|
||||
collapsedFooter := ""
|
||||
if opts.bodyCap > 0 && !toolCardAlwaysExpands(name) {
|
||||
collapsedFooter = collapsedToolFooter(row.detail)
|
||||
if opts.bodyCap > 0 && (!toolCardAlwaysExpands(name) || opts.compactEdit) {
|
||||
// In compact-edit mode an edit card collapses regardless of length: its diff
|
||||
// is already shown in the live Code card, so leaving even a short diff
|
||||
// expanded here duplicates it. Force the footer for edit tools in that mode.
|
||||
forceCollapse := opts.compactEdit && toolCardAlwaysExpands(name)
|
||||
collapsedFooter = collapsedToolFooter(row.detail, forceCollapse)
|
||||
}
|
||||
if collapsedFooter != "" && !row.expanded {
|
||||
head := toolCardHead(name, rc.hints[key], rc.args[key], "", glyph, rc.auto[key], width, opts)
|
||||
@@ -975,14 +996,16 @@ func toolCardAlwaysExpands(name string) bool {
|
||||
// collapsedToolFooter summarizes the hidden output for a collapsed tool card, or
|
||||
// "" when the output is short enough to render inline. Only output longer than
|
||||
// the live body cap (the noisy "… N more lines" case, e.g. a web-search dump)
|
||||
// collapses by default.
|
||||
func collapsedToolFooter(detail string) string {
|
||||
// collapses by default. When force is set the footer is produced regardless of
|
||||
// length (compact-edit mode, where even a short edit diff must collapse so it is
|
||||
// not duplicated by the live Code card) — but empty output never gets a footer.
|
||||
func collapsedToolFooter(detail string, force bool) string {
|
||||
trimmed := strings.TrimRight(detail, "\n")
|
||||
if strings.TrimSpace(trimmed) == "" {
|
||||
return ""
|
||||
}
|
||||
n := strings.Count(trimmed, "\n") + 1
|
||||
if n <= cardBodyMaxLines {
|
||||
if n <= cardBodyMaxLines && !force {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("▸ %d lines — click to expand", n)
|
||||
|
||||
@@ -72,6 +72,7 @@ func (m model) handleSpecCommand(task string) (tea.Model, tea.Cmd) {
|
||||
m.activeRunID = m.runID
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
m.runStartedAt = m.now()
|
||||
return m, tea.Batch(m.runAgentWithOptions(m.activeRunID, runCtx, task, turnImages, tuiAgentRunOptions{
|
||||
registry: specRegistry,
|
||||
permissionMode: agent.PermissionModeSpecDraft,
|
||||
@@ -220,6 +221,7 @@ func (m model) approveSpecReview() (tea.Model, tea.Cmd) {
|
||||
// counter so the cadence doesn't carry over a partial countdown.
|
||||
m.workingVerbTicks = 0
|
||||
m.workingVerb.Reset()
|
||||
m.runStartedAt = m.now()
|
||||
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ func (m model) toggleDetailedTranscript() model {
|
||||
}
|
||||
|
||||
func (m model) detailedTranscriptView() string {
|
||||
width := chatWidth(m.width)
|
||||
width := m.chatAreaWidth()
|
||||
rc := buildRowContext(m.transcript)
|
||||
renderer := m
|
||||
renderer.pending = false
|
||||
|
||||
Reference in new issue
Block a user