Compare commits

...
Author SHA1 Message Date
Gnanam 809b0d3a15 fix: address review on #228 (agent retry, sandbox risk split, TUI panels)
Agent loop (internal/agent):
- Route INITIAL StreamCompletion transient failures through the network-retry
  flow too — a TLS/dial timeout on the call itself was returned immediately,
  skipping retries (only the collect phase was covered). Annotate after retries.
- Exclude reasoning blocks and dropped tool-call signals from the "no output"
  retry/annotation guard via a shared streamProducedNoOutput helper, so a stream
  that surfaced reasoning is not re-sent (which would duplicate output).

Sandbox risk/engine (internal/sandbox):
- Split catastrophic detection from the broad destructive matcher. `rm -rf *`
  (and other workspace-local deletes) are now destructive-but-promptable instead
  of hard-denied; only system-level targets (/, $HOME, ~), mkfs/dd/fork-bomb/
  chmod-system/chown-R stay catastrophic.
- Close the path-traversal escape: a destructive command containing a `..`
  component (e.g. `rm -rf ../../`) is tagged destructive_catastrophic via both a
  regex target and an AST + traversal backstop, so it stays denied even in unsafe
  mode rather than escaping the workspace.

TUI (internal/tui):
- Gate rightColumnBase by drawable height (height > planPanelReserveRows) so
  activation matches composeWithPlanPanel, which overlays zero rows on a short
  terminal.
- Force-collapse short edit diffs in compact-edit mode so a live Code card does
  not duplicate the diff inline.

Tests cover each: initial-call retry + exhaustion, reasoning/dropped no-retry,
the scoped/catastrophic/traversal split, the height gate, and the short-diff
compact collapse.
2026-06-17 12:00:37 +05:30
Gnanam 196b9afd28 chore(providers): drop dead presets that no longer resolve to a working endpoint
Removes two built-in openAI-compatible presets whose endpoints no longer
resolve, so a fresh open-source setup only lists providers that actually
work. Curated model lists and the registry mapping are pruned to match.
2026-06-17 00:11:38 +05:30
Gnanam 4bbe62aedb tui: live Code card — diff of the file being edited, in the right column
Adds the bottom half of the IDE-style right column: a "Code" card stacked
under the Plan/Progress card, showing the live unified diff of the file the
active run is editing — green additions / red removals, the same palette as
the inline diff cards. The card carries the path and a +adds/−dels tally and
tracks the most recent edit in the run.

While the Code card is showing a run's diff, the inline edit cards in the
chat collapse to a one-line record (full diff is one click away, and returns
inline once the run ends), so the change lives in the card instead of being
duplicated in the middle of the conversation. This is live-region only — the
detailed view (Ctrl+O) and inline scrollback keep the full diffs.

The right column is gated to an active run that actually planned or edited
(never a trivial "hi"), is content-sized, and reserves the bottom chat rows
so a tall plan+diff column can never bury the conversation or the composer.

- code_panel.go: currentEditDiff/diffPath/diffCounts, renderCodeCard,
  codeDiffLines/codeBand, codePanelActive
- plan_panel.go: rightColumnBase gate, renderRightColumn stacks Plan+Code,
  composeWithPlanPanel overlays the column with a bottom reserve
- rendering.go/render_cache.go: compactEdit option collapses edit cards in
  the live region, with a cache key + stability fix so it never serves stale
- tests for the diff parsing, gating, stacking order, collapse, and reserve
2026-06-17 00:11:07 +05:30
Gnanam bb3b7544a4 fix(tui): keep update_plan out of the chat (it lives in the plan panel)
The agent re-dumped the entire "Current Plan: 1. [completed] …" on every
update_plan call, cluttering the transcript — and the plan is already shown
live in the right-side plan panel (and on demand via /plan). Skip update_plan's
tool call and result rows from the chat so the transcript stays focused on the
actual work (reads, edits, answers). Test added.
2026-06-17 00:11:07 +05:30
Gnanam b27cde63ca feat(agent): actionable message when a provider is unreachable after retries
A network failure that survives every retry (e.g. a provider whose host is
unreachable from the user's network) surfaced as a cryptic "TLS handshake
timeout". Annotate it once retries are exhausted: it's a network/connectivity
problem, not the model — check the connection/VPN or switch providers with
/provider. Test covers the annotation after the retries fail.
2026-06-17 00:11:07 +05:30
Gnanam 5a573bbe34 fix(tui): plan panel is a compact top-right widget, only during a planned run
The panel was a full-height "long box" that also showed a STALE plan for
trivial follow-ups (a plain "hi" still displayed the previous task's plan),
and long steps were cut mid-word.

- It now FLOATS over the top-right corner (composeWithPlanPanel overlays only
  its own rows), so the transcript stays full width — no reserved column, no
  full-height box. chatAreaWidth is now just the full chat width.
- It shows ONLY while a run is in flight AND that run actually called
  update_plan (currentRunHasPlan), so "hi" / a delete task never shows a stale
  plan, and it disappears the moment the run finishes.
- Steps truncate with an ellipsis (cutRunesEllipsis) instead of a hard break,
  and the box is wider (40 cols).

Tests rewritten for the gate + overlay (shows only during a planned run; hidden
for trivial/narrow/setup/inline; floats top-right full width). Gate green.
2026-06-17 00:11:06 +05:30
Gnanam eb4fe8fd4e feat(agent): auto-retry transient network failures in the provider stream
A one-off TLS handshake / dial timeout (or connection reset) reaching the
provider killed the whole turn — the user saw a red "provider stream error:
… TLS handshake timeout" and had to resend. These are connection-level blips
where the request never reached the model, so they're safe to retry.

After collecting a turn's stream, if it errored with NO output produced and
the error looks transient (TLS handshake / i/o / dial timeout, connection
reset/refused, unexpected EOF, transient DNS), re-send the SAME request after
an exponential backoff (1s, 2s; up to 2 retries). A failure that already
produced text/tool calls is NOT retried (would duplicate output); a
cancellation, an HTTP status error, or a context-length error is left to the
normal handling (the latter still triggers reactive compaction). The backoff
respects context cancellation.

internal/agent/stream_retry.go classifies the error and backs off;
Options.OnNetworkRetry surfaces each retry, which the TUI shows as a "network
issue reaching the provider — retrying (attempt N)…" row so the pause isn't
silent. Tests: classification (transient vs auth/rate-limit/context), backoff,
retry-to-success, no-retry-on-terminal, no-retry-after-partial-output.
gofmt/vet/build(host+linux+windows)/full -race/staticcheck green.
2026-06-17 00:11:06 +05:30
Gnanam 5dc62226bd feat(tui): right-docked live plan/progress panel
Long tasks looked stuck — the agent would say "I'll build…" then work
silently. Dock a panel on the right of the chat (alt-screen) that shows the
update_plan steps with status glyphs (○ pending, ◐ in-progress, ✓ done) and
an animated header with the current activity — Planning / Building / Scanning
/ Running / Responding / Thinking — plus the spinner and elapsed time. The
activity is derived from the running tool (or streaming state), so the panel
is a live "it's not stuck" signal.

Width is a single source of truth: m.chatAreaWidth() = chatWidth - the panel
column when docked, else chatWidth, so every renderer AND mouse hit-test uses
the same narrowed width and clicks never desync from what's drawn. The panel
shows only when alt-screen + not setup + wide enough (>= 52-col chat) + a plan
or run is live; otherwise chatAreaWidth == chatWidth and every path is byte-
for-byte unchanged. composeWithPlanPanel docks the panel row-by-row in View().

Adds runStartedAt (for elapsed) and plan_panel.go; ~20 chatWidth(m.width)
call-sites moved to m.chatAreaWidth(). Tests cover activation/width-narrowing,
hidden cases (narrow/setup/inline), side-by-side compose, the activity label
(incl. from a running tool), glyphs, and elapsed formatting. gofmt/vet/build
(host+linux+windows)/full -race/staticcheck green.
2026-06-17 00:11:00 +05:30
Gnanam 9c016ef2ec fix(sandbox): prompt for scoped destructive commands instead of hard-blocking
`rm -rf <subdir>` was classified "destructive" by the AST analyzer and
hard-denied by the sandbox before any permission prompt — so deleting a
directory the agent had created was impossible, forcing an ask_user
workaround. The deny also fired before the unsafe-mode allow, so bypass
mode couldn't run it either.

Split destructive into two tiers:
- "destructive_catastrophic" — the irrecoverable system-level forms the
  catastrophic regex already isolates (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.
- everything else flagged destructive (e.g. rm -rf <subdir>, shred <file>) is
  now a Prompt in ask mode — surfaced to the (clickable) permission popup so
  the user can approve — and an Allow in unsafe mode.

risk.go tags the catastrophic set with the new category; engine.go branches
deny / fall-through-to-allow / prompt accordingly. Adds tests pinning the
split (scoped → prompt/allow, catastrophic → deny even in unsafe). Existing
sandbox/agent/cli/tools tests stay green; gofmt/vet/build(host+linux+windows)/
staticcheck clean.
2026-06-17 00:05:47 +05:30
27 changed files with 1506 additions and 46 deletions

No files matched your search

+59 -5
View File
@@ -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
+102
View File
@@ -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
}
}
+234
View File
@@ -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)
}
}
+4
View File
@@ -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
-2
View File
@@ -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
+1 -4
View File
@@ -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 {
-8
View File
@@ -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"},
},
-2
View File
@@ -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",
}
+83
View File
@@ -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)
}
}
}
+15 -1
View File
@@ -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)
+68
View File
@@ -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)
+161
View File
@@ -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)
}
+216
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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),
+2 -2
View File
@@ -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) {
+293
View File
@@ -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")
}
+180
View File
@@ -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)
}
}
}
+18
View File
@@ -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")
}
}
+7
View File
@@ -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)
+32 -9
View File
@@ -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)
+2
View File
@@ -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)
}
+1 -1
View File
@@ -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