Compare commits

...
Author SHA1 Message Date
Gnanam b8d23f1ce2 fix(tui): address FILES sidebar review findings
Review fixes for the FILES panel / drill-in / git sweep:

- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
  apply_patch spanning several files no longer charges the whole patch's
  +/- totals to every file it touched; order by lastRowIndex descending
  so a re-touched file lists first (reversing first-seen order kept it in
  its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
  "+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
  rename records (counts + preimage + postimage) — plain --numstat
  brace-mangles renamed paths ("src/{old => new}"), so their diffstat
  never matched a porcelain path and renamed files stayed at 0/0;
  gitTouchedFiles now yields newest detection first to match the
  roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
  (os.ReadFile loaded a multi-GB file wholesale before truncating — the
  exact freeze the cap exists to prevent); re-opening the file already
  being viewed is a no-op so a stray re-click can't bounce full mode
  back to diff or reset scroll; skip sub-4-char lines in the changed-line
  markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
  permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
  shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
  rename postimage key; new coverage for per-file stats, re-touch
  ordering, live-row overflow, same-file reopen, truncation, modal
  gating, live-write content, and a real-repo rename sweep.
2026-07-02 12:03:27 +05:30
Kevin Codex 0b853598e4 feat(tui): FILES sidebar panel with click-to-select and file drill-in
Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.

Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.

The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.
2026-07-02 11:20:57 +08:00
KRATOS 02f0c0929b fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall (#362)
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall

Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck"
reports: the ChatGPT Codex backend pauses real generation mid-stream on
large single tool-call outputs (a ~1000-line write_file) while continuing
SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug
or a connection-pooling issue (the darwin DisableKeepAlives fix is applied
and confirmed not the cause). Zero already detected it, but two UX gaps
made it feel like a hang:

1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes.
   A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a
   frozen token counter was a terrible wait for a doomed turn. Replaced the
   StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2
   (6 minutes at the default). It still only ever fires on a TRUE zero-output
   freeze — any real data line resets it — so healthy slow-reasoning runs are
   unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled)
   when idle is disabled.

2. Auto-retry the actual stall. The existing stall-retry only fired when
   NOTHING had been forwarded — so the common case (model streamed transient
   reasoning + began the big write_file, then froze mid-arguments) was NOT
   retried, because the incomplete tool call is collected and transient
   output was forwarded. Relaxed the gate to retry when no visible PROSE was
   shown (forwardedVisibleText, marked only by OnText) and no final text was
   collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete
   tool call from a timed-out stream is never executed (the turn returns on
   the error before dispatch) and never appended to messages, so the retry
   re-sends clean context with no duplication. A visible
   "[no output — model stalled; retrying N/max]" notice fires per attempt.
   Forwarded prose still blocks the retry (it would duplicate answer text).

Verified: full -race suite green; a 4-dimension root-cause audit and a
3-dimension adversarial review of this diff both passed with no confirmed
defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact
gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved
TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry).

* fix(providerio): make ContentStallTimeout overflow-safe

Address PR #362 review: idleTimeout*6/5 could overflow int64 for a
pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE
duration, which would arm the content-stall timer to fire immediately and
abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max
duration on overflow (which just means "effectively no content watchdog",
the sane result for an absurd idle timeout). The normal 1.2× values are
unchanged (5m->6m, 30s->36s, 100ms->120ms).

New test: TestContentStallTimeout covers the scaled values, the
zero/disabled case, and the MaxInt64 overflow guard.
2026-07-02 00:14:51 +05:30
KRATOS 3fde03a900 fix(tui): show the queued-message preview above the composer, not below (#361)
A message typed while a run is active is queued for the next turn. Its
"[queued] …" preview rendered on the line BELOW the input box; move it to
directly ABOVE the box so it reads as "waiting to send," sitting on top of
what you're currently typing, and doesn't get visually detached from the
composer by the status line beneath it. Footer height is unchanged (the
preview line just moves from below to above the box).

New test: TestQueuedPromptPreviewSitsAboveComposer asserts the preview
line index is above the composer box top border.
2026-07-01 23:55:54 +05:30
KRATOS 85273ea72d fix(providerio): disable HTTP keep-alive reuse on macOS to defeat degraded-connection stalls (#360)
This codebase already has a documented, previously-diagnosed macOS-only
bug: "sharedHTTPClient... defeats the stale-pooled-connection hang...
reproduced on BOTH chatgpt and ollama... surfaces on macOS far more than
Linux because macOS keeps dead pooled connections around longer." The
existing mitigation (ResponseHeaderTimeout=120s, IdleConnTimeout=30s)
catches a reused connection that never responds at all, or one that sat
idle too long.

Traced a live report that doesn't fit either case: a session accumulated
301 real completion tokens over ~12.5 minutes (~0.4 tok/s) on
chatgpt/gpt-5.5 before being manually cancelled - not silent, just
catastrophically slow, on a retry shortly after a prior attempt on the
same host. That's a third failure mode: a reused connection that's
alive-but-severely-degraded, still delivering real bytes (enough to keep
resetting Zero's stream idle/content-stall watchdogs, which only fire on
true silence) but never at a usable rate. Basic network diagnostics on
the affected machine (DNS, TCP connect, TLS handshake) were all healthy
and routed directly with no VPN/proxy involved, pointing at the same
connection-reuse class of bug rather than a local network misconfig.

DisableKeepAlives removes pooling from the equation entirely rather than
just tightening the timeout window, since a degraded-not-dead connection
isn't reliably distinguishable from genuine backend slowness once inside
the stream. Scoped to darwin only via runtime.GOOS: a fresh TCP+TLS
handshake per request costs low tens of milliseconds (measured ~92ms
against a real host on the affected machine) against potential
minutes-long stalls, and Linux/Windows don't exhibit this OS-level
pooled-connection behavior, so they keep full connection reuse.

New test: TestHTTPClientReturnsStallHardenedSharedClient extended to
assert DisableKeepAlives matches runtime.GOOS == "darwin".
2026-07-01 21:48:47 +05:30
KRATOS 0914ced9e1 test: widen timing margins on two Windows-flaky CI tests (#359)
Both failed on windows-latest today, on merges of otherwise-unrelated
PRs (#349, #356) - neither PR touched internal/specialist or
internal/agenteval. Windows GitHub Actions runners have coarser default
timer granularity and more scheduling contention than Linux/macOS, so
tight timing margins that hold on the other two platforms aren't
reliable there.

- TestOutputToolBlocksUntilTaskCompletes: a 100ms poll timeout against a
  5ms background write left only 20x margin. Widened the timeout to
  2000ms (a ceiling, not a target - PollInterval keeps the test fast in
  the success case; it only changes how much CI jitter it tolerates
  before failing).
- TestHarnessTimeoutCancelsBlockedAgent: confirmed there's no actual data
  race (harness.Agent.Run is a direct synchronous call, not a spawned
  goroutine racing the assertions) - the failure was workspace
  materialization alone consuming the full 10s budget on a contended
  Windows runner, so the agent was never reached. Widened to 30s. This
  test's own design blocks for the full timeout to observe cancellation,
  so unlike the other fix this directly costs ~20s more wall-clock time,
  but that's inherent to what it's testing.
2026-07-01 21:28:52 +05:30
KRATOS 68669073c2 fix(tui): explicitly request the initial window size at startup (#358)
Zero is always in alt-screen mode (useAltScreen() is unconditionally
true), and transcriptView's `if m.altScreen && m.height > 0` gate is what
pads the rendered frame out to fill the full terminal height
(scrollableTranscriptItemsView pads bodyWindow to window.height). If
m.height is ever 0 - Bubble Tea documents an initial WindowSizeMsg as
delivered automatically on program start, but that's the terminal
proactively pushing a size, and nothing else ever asks again if that
push is missed (a slow/unusual terminal, a multiplexer, a startup race)
- Zero falls back to the unpadded, non-fullscreen render path for the
rest of the session: content only occupies as many rows as it needs,
leaving the remaining terminal height as plain unfilled background
instead of the alt-screen's painted panel color.

Fixed by explicitly firing tea.RequestWindowSize in Init(), the same
defensive pattern already used for tea.RequestBackgroundColor right below
it - Zero doesn't rely solely on an unprompted push for either.
2026-07-01 21:25:47 +05:30
KRATOS 33410d8730 fix(tui): make the stall watchdog visible before it fires, not just after (#357)
Zero already has a content-stall watchdog built specifically for this
exact case (providerio.ErrStreamStalled's own doc comment: "observed on
chatgpt/gpt-5.x and ollama reasoning models") - a heartbeating-but-silent
upstream is aborted after idleTimeout * StreamContentStallFactor (5min *
2 = 10min by default). It works. The gap is that until it fires, the
"still generating... Xs" cue is the ONLY signal the user has, and it
looks completely identical whether real (if slow) content is still
coming or the stream is already dead and just hasn't been caught yet -
confirmed against a live report where the token count sat frozen for
7+ minutes with no way to tell from the UI alone.

Past half the effective idle timeout, quietGenerationHint() now escalates
to name what's actually happening and when Zero will act on its own:
"still generating... Xs - unusually quiet, Zero will auto-recover by
~Ys if it doesn't resume". Exported StreamContentStallFactor from
providerio (was unexported) so the TUI can compute the same ceiling the
watchdog itself uses, instead of duplicating the constant.

New test: TestQuietGenerationHintEscalatesPastHalfIdleTimeout, using
ZERO_STREAM_IDLE_TIMEOUT to shrink the timeout so the test runs fast
while exercising the exact same math the real watchdog uses.
2026-07-01 21:25:27 +05:30
24 changed files with 2321 additions and 82 deletions

No files matched your search

+36 -21
View File
@@ -210,27 +210,29 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
// never re-streams on top of output the user already saw — even across the
// reactive-compaction retry below, which overwrites `collected`.
forwardedAnything := false
mark := func() { forwardedAnything = true }
// OnUsage is telemetry, not visible output, so it does NOT mark
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
// tokens) then stalls with no content must still be safely retried. The gate
// only guards against re-streaming output the user actually SAW.
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
// the only output whose re-stream on a stall retry would visibly DUPLICATE
// answer text the user already read. Transient working indicators
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
// counted: a turn that streamed only those then stalled — the common
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
// the TUI resets the tool-call preview on the next tool-call-start and a
// stalled turn is never appended to `messages` (it returns on the error
// before the append), so the retry re-sends clean context with no
// conversation-state duplication.
forwardedVisibleText := false
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
@@ -284,15 +286,28 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout with NO output forwarded yet this turn can be
// safely re-issued on a fresh connection — nothing was shown, so there's no
// duplication (this recovers the macOS stale-pooled-connection hang when it
// slips past the transport's response-header timeout). A turn that already
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
// falls through to the error return below. Capped + exponential backoff.
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
// large write_file) then froze mid-arguments — the common gpt-5.x /
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
// (a turn with collected.Error returns before dispatch below) and NEVER
// appended to `messages`, so a retry re-issues clean context; the only
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
+15
View File
@@ -40,6 +40,21 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
}
}
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
// stream that connected and produced only transient output, then went silent).
// Distinct wording from reconnectNoticeFor's "connection lost" because the
// connection was fine — the model stalled. Surfaced through OnReasoning, the
// non-content channel, so it never folds into the answer text. Nil when there
// is no reasoning sink (the retry still happens silently).
func stallRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
+65 -19
View File
@@ -10,19 +10,25 @@ import (
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". When
// partialText is set it emits that text BEFORE the stall, simulating a stall after
// partial output (which must NOT be retried — re-issuing would duplicate it).
// timeout error for the first stallBefore calls, then succeeds with "done". The
// pre-stall emission simulates different partial-output shapes:
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
// (re-issuing would duplicate visible answer text).
// - reasoningText: transient reasoning preview — IS retried.
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
// "froze mid-write_file" case — IS retried (the incomplete call is never
// executed or committed).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
calls int32
stallBefore int32
partialText string
reasoningText string
partialToolCall string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 3)
ch := make(chan zeroruntime.StreamEvent, 5)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
@@ -30,9 +36,16 @@ func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.Comple
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
if p.partialToolCall != "" {
// A tool call that starts and streams a partial argument fragment but
// never gets StreamEventToolCallEnd, then the stream errors — exactly
// the gpt-5.x "began the big write_file then froze" shape.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 10m (the model produced nothing)",
Error: "provider stream error: no output for 6m (the model produced nothing)",
}
close(ch)
return ch, nil
@@ -72,21 +85,54 @@ func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
}
}
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
// retried: the user already saw the reasoning, so re-issuing with callbacks
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
// the turn-level forwarded-output gate specifically, not the text check.
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
// A stall after only transient REASONING was forwarded (no prose text, no
// completed tool call) IS retried: reasoning is a "thinking" preview, not
// committed answer text, so re-streaming it on a clearly-signalled retry is
// acceptable and recovers the common "reasoned then froze" stall. Only
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
// here, so this exercises the transient-vs-prose gate specifically).
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
_, err := Run(context.Background(), "go", p, Options{
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err == nil {
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
if err != nil {
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
// froze" case. The incomplete call is never executed (a turn returns on the
// stall error before dispatch) and never committed to history, so re-issuing is
// safe and recovers to success. This is the primary scenario this change fixes.
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
starts := 0
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
})
if err != nil {
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
}
}
+6 -2
View File
@@ -455,11 +455,15 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
// The timeout is longer than materialization of the small fixture even on
// slower Windows CI runners, so the agent is reached before it blocks until
// the deadline fires.
// the deadline fires. 10s wasn't generous enough in practice — it failed
// on an otherwise-unrelated PR merge (materialization alone consumed the
// full budget on a contended Windows runner, so the agent was never
// reached); widened for headroom, since the assertions below still bound
// worst-case test time via that same deadline, not this constant.
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
Timeout: 10 * time.Second,
Timeout: 30 * time.Second,
})
if !agentReached {
+49 -12
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -25,9 +27,9 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
// actual output for ContentStallTimeout(idle). Without this an upstream that
// heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama reasoning
// models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
@@ -38,7 +40,7 @@ var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", ContentStallTimeout(idleTimeout))
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
@@ -53,13 +55,32 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// ContentStallTimeout bounds a heartbeat-but-no-output stream: keep-alives reset
// the idle watchdog (a heartbeating upstream is not "dead"), but if NO real data
// line arrives for this long the stream is aborted (ErrStreamStalled). Scaled
// above idleTimeout (so a slow-but-producing request that emits data between
// heartbeats is never killed — the watchdog only ever fires when NOTHING real
// arrives), but only 1.2× rather than the old 2×: a genuine heartbeat-pause
// stall on chatgpt/gpt-5.x rarely recovers, so a ~10-minute dead wait (at the 5m
// idle default) was a terrible UX for a doomed turn. At the default idle this is
// 6 minutes; it still scales with ZERO_STREAM_IDLE_TIMEOUT. A returned value <= 0
// (idle watchdog disabled) leaves the content watchdog off too.
func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
if idleTimeout <= 0 {
return 0
}
// 1.2× computed as idle + idle/5 (not idle*6/5), plus a clamp: a
// pathologically large ZERO_STREAM_IDLE_TIMEOUT could make idle*6 overflow
// int64 and wrap to a NEGATIVE duration, which would arm the content timer
// to fire immediately and abort every stream. Clamping to the max duration
// on overflow just means "effectively no content watchdog" — the sane
// result for an absurd idle timeout.
extra := idleTimeout / 5
if idleTimeout > math.MaxInt64-extra {
return math.MaxInt64
}
return idleTimeout + extra
}
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
@@ -122,6 +143,21 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
// unaffected (the stream-idle + content watchdogs cover that).
// - IdleConnTimeout is shortened so a connection that went idle across a pause
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
// - DisableKeepAlives on darwin only: ResponseHeaderTimeout/IdleConnTimeout
// above catch a reused connection that's fully dead (never responds) or
// idle past the timeout, but not one that's alive-but-severely-degraded —
// e.g. reused shortly after a prior request on the same host (well within
// 30s, common across quick retries of the same turn), where it still
// delivers real bytes, just at a crippled rate, resetting Zero's stream
// idle/content-stall watchdogs (which only fire on true silence) without
// ever recovering. That degraded-not-dead case is indistinguishable from
// genuine backend slowness from inside the stream, so the only reliable
// fix is removing pooling from the equation entirely on the one platform
// where this class of bug has actually reproduced. A fresh TCP+TLS
// handshake per request costs low tens of milliseconds — negligible next
// to the minutes-long stalls this avoids — and this doesn't touch
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
// pooled connections around as long.
var sharedHTTPClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
@@ -132,6 +168,7 @@ var sharedHTTPClient = func() *http.Client {
// content-stall watchdogs.
transport.ResponseHeaderTimeout = 120 * time.Second
transport.IdleConnTimeout = 30 * time.Second
transport.DisableKeepAlives = runtime.GOOS == "darwin"
return &http.Client{Transport: transport}
}()
@@ -285,7 +322,7 @@ func ScanSSEDataWithContext(
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := idleTimeout * streamContentStallFactor
contentTimeout := ContentStallTimeout(idleTimeout)
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"io"
"math"
"net/http"
"runtime"
"strings"
"testing"
"time"
@@ -203,11 +205,36 @@ func TestUpstreamUnreachable(t *testing.T) {
}
}
// ContentStallTimeout is 1.2× the idle timeout, disabled (0) when idle is
// disabled, and must never overflow to a negative duration for an absurdly
// large idle timeout (which would arm the content timer to fire immediately
// and abort every stream).
func TestContentStallTimeout(t *testing.T) {
cases := []struct {
idle time.Duration
want time.Duration
}{
{5 * time.Minute, 6 * time.Minute},
{30 * time.Second, 36 * time.Second},
{100 * time.Millisecond, 120 * time.Millisecond},
{0, 0},
{-1, 0},
}
for _, c := range cases {
if got := ContentStallTimeout(c.idle); got != c.want {
t.Fatalf("ContentStallTimeout(%v) = %v, want %v", c.idle, got, c.want)
}
}
// Overflow guard: idle*6 would wrap negative; the clamp keeps it positive.
if got := ContentStallTimeout(math.MaxInt64); got <= 0 {
t.Fatalf("ContentStallTimeout(MaxInt64) = %v, must stay positive (no overflow wrap)", got)
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
@@ -235,7 +262,8 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
// Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
@@ -258,7 +286,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 200ms content window,
// One data line every 30ms for ~300ms (well past the 120ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
@@ -290,16 +318,16 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
// it reports the content window (ContentStallTimeout) and must NOT claim the
// upstream "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
if !strings.Contains(stalled, "no output for 6m") {
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
@@ -324,6 +352,17 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
if tr.IdleConnTimeout != 30*time.Second {
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
}
// DisableKeepAlives is the stronger mitigation scoped to darwin only: the
// two timeouts above catch a reused connection that's fully dead or idle
// too long, but not one that's alive-and-degraded (still delivers real
// bytes, just at a crippled rate) — which resets Zero's stream watchdogs
// without ever recovering. Removing pooling from the equation entirely is
// only worth its reconnect cost on the platform this class of bug has
// actually reproduced on.
wantDisableKeepAlives := runtime.GOOS == "darwin"
if tr.DisableKeepAlives != wantDisableKeepAlives {
t.Fatalf("DisableKeepAlives = %v, want %v (GOOS=%s)", tr.DisableKeepAlives, wantDisableKeepAlives, runtime.GOOS)
}
if HTTPClient(nil) != got {
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
}
+7 -1
View File
@@ -124,10 +124,16 @@ func TestOutputToolBlocksUntilTaskCompletes(t *testing.T) {
tool := NewOutputTool(manager)
tool.PollInterval = time.Millisecond
// timeout is a ceiling, not a target: the test still returns as soon as the
// 5ms write lands (PollInterval keeps it responsive), it just tolerates a
// slow/contended CI runner without failing. A 100ms budget against a 5ms
// write only left 20x margin, which Windows CI runners (coarser default
// timer granularity, heavier scheduling contention than Linux/macOS) blew
// through and failed the test on an otherwise-unrelated PR merge.
result := tool.Run(context.Background(), map[string]any{
"task_id": "child_task",
"block": true,
"timeout": 100,
"timeout": 2000,
})
if result.Status != tools.StatusOK || !strings.Contains(result.Output, "output:\nfinished") {
+251
View File
@@ -0,0 +1,251 @@
// file_view.go is the drill-in view for a touched file, opened from the FILES
// sidebar section (files_panel.go). It reuses the subchat pattern: while
// active, the chat column's body swaps to this file's content — the sidebar,
// composer, and scroll engine keep working unchanged (transcriptBodyItems is
// the single source the viewport, renderer, and hit-testers all read, so
// swapping there keeps every consumer consistent). Two modes:
//
// diff (default) — the file's edit cards from this session, full-depth
// full — the file as it stands on disk, syntax highlighted, with
// gutter markers on the lines this session's diffs added
//
// d/f switch modes (with an empty composer), Esc returns to the chat at the
// scroll position it was left at.
package tui
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileViewMaxLines caps the full-file mode so a giant generated file can't
// freeze a render; the tail collapses into a "… N more lines" trailer.
const fileViewMaxLines = 4000
const (
fileViewDiff = iota
fileViewFull
)
// fileViewState manages the drill-in view for a touched file. When active, the
// transcript body swaps to the file's diff/content instead of the chat rows.
type fileViewState struct {
active bool
path string // workspace-relative, as carried by changedFiles
mode int // fileViewDiff | fileViewFull
// parentScrollOffset preserves the chat scroll position so closing the view
// returns to the same spot (mirrors subchatState).
parentScrollOffset int
}
// openFileView activates the drill-in for path in diff mode. Opening from an
// already-open view (clicking another FILES row) keeps the original saved chat
// scroll position rather than saving the file view's own offset as "parent".
// Re-opening the file that is ALREADY being viewed is a no-op: a stray
// re-click must not bounce the user from full mode back to diff or reset
// their scroll position.
func (m model) openFileView(path string) model {
if m.fileView.active && m.fileView.path == path {
return m
}
if !m.fileView.active {
m.fileView.parentScrollOffset = m.chatScrollOffset
}
m.fileView.active = true
m.fileView.path = path
m.fileView.mode = fileViewDiff
// A file only the git sweep knows about (bash/subagent mutation) has no edit
// cards to stack — open straight on the full file instead of a placeholder.
if len(m.fileViewResultRows()) == 0 {
m.fileView.mode = fileViewFull
}
m.chatScrollOffset = 0
m = m.clearHover() // bodyY numbering differs between the file body and the chat
return m
}
// exitFileView deactivates the view and restores the chat scroll position.
func (m model) exitFileView() model {
if !m.fileView.active {
return m
}
m.chatScrollOffset = m.fileView.parentScrollOffset
m.fileView = fileViewState{}
m = m.clearHover()
return m
}
// setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored
// start since the two bodies have unrelated heights.
func (m model) setFileViewMode(mode int) model {
if !m.fileView.active || m.fileView.mode == mode {
return m
}
m.fileView.mode = mode
m.chatScrollOffset = 0
return m
}
// fileViewNavBar renders the single-line header shown in place of the pinned
// title bar while the view is active: the path plus the key hints. One line
// exactly, so every scrollableTranscriptFrame computed against it agrees with
// the title bar's geometry.
func (m model) fileViewNavBar(width int) string {
mode := "diff"
other := "f full"
if m.fileView.mode == fileViewFull {
mode = "full"
other = "d diff"
}
left := zeroTheme.accent.Render("← "+truncatePathLeft(m.fileView.path, maxInt(8, width/2))) +
zeroTheme.faint.Render(" · "+mode)
right := zeroTheme.faint.Render(other + " · esc back")
return fitStyledLine(joinHeaderLine(left, right, width), width)
}
// fileViewBodyItems builds the body items the transcript machinery renders
// while the view is active — one pre-rendered block, so scrolling and height
// accounting flow through the exact same path as chat rows.
func (m model) fileViewBodyItems(width int) []transcriptBodyItem {
var block string
if m.fileView.mode == fileViewFull {
block = m.renderFileViewFull(width)
} else {
block = m.renderFileViewDiff(width)
}
return []transcriptBodyItem{transcriptBlockBodyItem(transcriptBodyItemRow, -1, block)}
}
// fileViewResultRows returns the transcript's tool-result rows that touched the
// viewed file, in chronological order.
func (m model) fileViewResultRows() []transcriptRow {
var rows []transcriptRow
for _, row := range m.transcript {
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == m.fileView.path {
rows = append(rows, row)
break
}
}
}
return rows
}
// renderFileViewDiff renders the session's edits to the file as its full-depth
// tool cards (bodyCap 0, the detailed-view depth), stacked chronologically —
// the same cards the chat shows, so the diffs read identically in both places.
func (m model) renderFileViewDiff(width int) string {
rows := m.fileViewResultRows()
if len(rows) == 0 {
return zeroTheme.faint.Render("No recorded edits for this file in this session.")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
var b strings.Builder
for i, row := range rows {
if i > 0 {
b.WriteString("\n\n")
}
if len(rows) > 1 {
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("edit %d of %d", i+1, len(rows))))
b.WriteString("\n")
}
b.WriteString(m.renderRowModeUncached(row, width, rc, opts))
}
return b.String()
}
// renderFileViewFull renders the file as it currently stands on disk, syntax
// highlighted, with a line-number gutter and an accent ▎ marker on the lines
// this session's diffs added (matched by exact text — an approximation that
// tolerates later drift; a stale marker just doesn't highlight).
func (m model) renderFileViewFull(width int) string {
target := m.fileView.path
if !filepath.IsAbs(target) {
target = filepath.Join(m.cwd, target)
}
// Stream the read and stop at the cap: os.ReadFile would load a multi-GB
// file wholesale before any truncation, which is the exact render freeze
// fileViewMaxLines exists to prevent.
file, err := os.Open(target)
if err != nil {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
defer file.Close()
var lines []string
truncated := false
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for scanner.Scan() {
if len(lines) == fileViewMaxLines {
truncated = true
break
}
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
if len(lines) == 0 {
return zeroTheme.faint.Render("Could not read file: " + err.Error())
}
truncated = true // e.g. a single over-long line mid-file: show what we have
}
changed := m.fileViewChangedLines()
gutterW := len(fmt.Sprintf("%d", len(lines)))
textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column
// Highlight with an effectively-infinite measure so the highlighter never
// wraps — output lines stay 1:1 with file lines and the gutter numbering
// can't desync. Each line is then truncated to the column budget below.
display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil)
if !ok || len(display) != len(lines) {
display = lines // no lexer for this path: render plain
}
var b strings.Builder
for i, line := range display {
line = fitStyledLine(line, textBudget)
if i > 0 {
b.WriteString("\n")
}
marker := " "
if changed[strings.TrimSpace(lines[i])] {
marker = zeroTheme.accent.Render("▎")
}
b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1)))
b.WriteString(marker)
b.WriteString(line)
}
if truncated {
// No exact remaining-line count: computing one would require reading the
// rest of the file, defeating the bounded read above.
b.WriteString("\n")
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines))))
}
return b.String()
}
// fileViewChangedLines collects the trimmed text of every line the session's
// diffs ADDED to the viewed file, for the full-mode gutter markers. Very short
// lines ("}", "return", ")") are skipped: text-matching them would mark every
// unrelated occurrence across the file, which misleads far more than a missing
// marker on a brace line ever could.
func (m model) fileViewChangedLines() map[string]bool {
changed := map[string]bool{}
for _, row := range m.fileViewResultRows() {
for _, line := range strings.Split(row.detail, "\n") {
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); len(text) >= 4 {
changed[text] = true
}
}
}
return changed
}
+288
View File
@@ -0,0 +1,288 @@
package tui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
)
// TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position,
// resets it for the file body, and Esc restores it; switching files while open
// keeps the ORIGINAL saved position (not the file view's own).
func TestFileViewOpenExitRestoresScroll(t *testing.T) {
m := filesPanelTestModel()
m.chatScrollOffset = 12
m = m.openFileView("web/app.js")
if !m.fileView.active || m.fileView.mode != fileViewDiff {
t.Fatalf("open should activate in diff mode: %+v", m.fileView)
}
if m.chatScrollOffset != 0 || m.fileView.parentScrollOffset != 12 {
t.Fatalf("open should reset scroll and save the parent offset: offset=%d saved=%d", m.chatScrollOffset, m.fileView.parentScrollOffset)
}
m.chatScrollOffset = 5 // scrolled within the file body
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.parentScrollOffset != 12 {
t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset)
}
m = m.exitFileView()
if m.fileView.active || m.chatScrollOffset != 12 {
t.Fatalf("exit should restore the chat scroll: active=%v offset=%d", m.fileView.active, m.chatScrollOffset)
}
}
// TestFileViewEscAndModeKeys: Esc exits the view via the model's key handler;
// d/f switch modes while the composer is empty and never while typing.
func TestFileViewEscAndModeKeys(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewFull {
t.Fatal("f should switch to full mode")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("d should switch back to diff mode")
}
// With text in the composer, d/f type as normal characters.
m.input.SetValue("say")
updated, _ = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f while typing must not hijack the composer")
}
m.input.SetValue("")
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if m.fileView.active {
t.Fatal("Esc should exit the file view")
}
}
// TestFileViewDiffBody: diff mode stacks the file's edit cards chronologically
// with "edit N of M" labels; a file with no recorded edits shows the quiet
// placeholder.
func TestFileViewDiffBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
body := plainRender(t, m.renderFileViewDiff(78))
if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") {
t.Fatalf("expected chronological edit labels:\n%s", body)
}
if !strings.Contains(body, "added one") || !strings.Contains(body, "three") {
t.Errorf("expected both diffs' content:\n%s", body)
}
m.fileView.path = "never/touched.go"
if got := plainRender(t, m.renderFileViewDiff(78)); !strings.Contains(got, "No recorded edits") {
t.Errorf("untouched file should show the placeholder, got:\n%s", got)
}
}
// TestFileViewFullBody: full mode shows the on-disk content with line numbers
// and marks session-added lines with the gutter marker; a missing file degrades
// to a readable error line.
func TestFileViewFullBody(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("let a = 1\nlet untouched = 0\n"), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "write_file", id: "w9", status: tools.StatusOK,
detail: "+let a = 1",
changedFiles: []string{"app.js"},
})
m = m.openFileView("app.js")
m = m.setFileViewMode(fileViewFull)
body := m.renderFileViewFull(78)
plain := plainRender(t, body)
if !strings.Contains(plain, "1 ") || !strings.Contains(plain, "let untouched = 0") {
t.Fatalf("full view should show numbered file content:\n%s", plain)
}
lines := strings.Split(plain, "\n")
if len(lines) != 2 {
t.Fatalf("one rendered line per file line, got %d:\n%s", len(lines), plain)
}
if !strings.Contains(lines[0], "▎") {
t.Errorf("session-added line should carry the gutter marker: %q", lines[0])
}
if strings.Contains(lines[1], "▎") {
t.Errorf("untouched line must not carry the marker: %q", lines[1])
}
m.fileView.path = "gone.js"
if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") {
t.Errorf("missing file should degrade to an error line, got:\n%s", got)
}
}
// TestFileViewSwapsTranscriptBody: while active, transcriptBodyItems returns
// the file body (a single block) instead of the chat rows, and the pinned
// title bar swaps to the one-line nav bar — the geometry every frame consumer
// relies on.
func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
nav := plainRender(t, m.pinnedTitleBar(m.chatColumnWidth()))
if !strings.Contains(nav, "sidebar.go") || !strings.Contains(nav, "esc back") {
t.Fatalf("nav bar should show the path and key hints: %q", nav)
}
if lines := len(viewLines(m.fileViewNavBar(m.chatColumnWidth()))); lines != 1 {
t.Fatalf("nav bar must be exactly one line (title-bar geometry), got %d", lines)
}
// The whole view renders without panicking in both modes and shows the nav.
if view := plainRender(t, m.transcriptView()); !strings.Contains(view, "esc back") {
t.Fatal("transcript view should carry the file nav bar")
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.sessionStore = store
m.swarmSessionMap = map[string]string{"subagent-1": "sess-1"}
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build it", runID: 1},
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1},
)
m.activeRunID = 1
m = m.openFileView("web/app.js")
width := sidebarWidth(m.width)
agents := m.sidebarAgentSelectables(width)
if len(agents) == 0 {
t.Fatal("expected a clickable agent row")
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
}
}
// TestChangedFilesRehydration: a persisted tool-result payload's changedFiles
// restores onto the rehydrated transcript row, so the FILES panel survives
// /resume.
func TestChangedFilesRehydration(t *testing.T) {
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
got := rows[0].changedFiles
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
t.Fatalf("changedFiles not rehydrated: %v", got)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
func TestOpenFileViewSamePathIsNoOp(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m = m.setFileViewMode(fileViewFull)
m.chatScrollOffset = 7 // scrolled within the file body
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewFull {
t.Fatal("re-opening the same file must keep full mode")
}
if m.chatScrollOffset != 7 {
t.Fatalf("re-opening the same file must keep the scroll, got %d", m.chatScrollOffset)
}
// A DIFFERENT file still switches (and resets to diff mode as documented).
m = m.openFileView("internal/tui/sidebar.go")
if m.fileView.path != "internal/tui/sidebar.go" || m.fileView.mode != fileViewDiff {
t.Fatalf("opening another file should switch views: %+v", m.fileView)
}
}
// TestFileViewFullBodyTruncatesLongFile: the full view stops reading at
// fileViewMaxLines (bounded read — a giant file must not be loaded wholesale)
// and appends the truncation trailer.
func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
dir := t.TempDir()
var b strings.Builder
for i := 0; i < fileViewMaxLines+50; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
if err := os.WriteFile(filepath.Join(dir, "big.txt"), []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
m := filesPanelTestModel()
m.cwd = dir
m.gitTouched = []gitSweepFile{{path: "big.txt"}}
m = m.openFileView("big.txt")
plain := plainRender(t, m.renderFileViewFull(80))
lines := strings.Split(plain, "\n")
if len(lines) != fileViewMaxLines+1 { // capped content + the trailer line
t.Fatalf("rendered %d lines, want %d content lines + 1 trailer", len(lines), fileViewMaxLines)
}
if !strings.Contains(lines[len(lines)-1], "truncated") {
t.Fatalf("expected the truncation trailer, got %q", lines[len(lines)-1])
}
}
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
func TestFileViewKeysDeferToBlockingModal(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("web/app.js")
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "write_file"},
decide: func(agent.PermissionDecision) {},
}
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
m = updated.(model)
if m.fileView.mode != fileViewDiff {
t.Fatal("f with a permission prompt up must not switch file-view modes")
}
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
if !m.fileView.active {
t.Fatal("Esc with a permission prompt up must not exit the file view")
}
}
+237
View File
@@ -0,0 +1,237 @@
// files_git_sweep.go fills the FILES sidebar's blind spot: mutations that
// bypass the file tools entirely — bash/exec_command scaffolding (npm create,
// go generate, heredoc writes) and subagents editing the shared workspace. None
// of those produce a changedFiles-carrying tool result, so the transcript-derived
// roster (files_panel.go) never sees them. The sweep asks git instead: a
// baseline `git status --porcelain` snapshot is taken at startup (Init), and a
// re-run after each command tool result / turn end reports any NEWLY dirty
// paths, which merge into the roster with a diffstat from `git diff --numstat`.
// Pre-existing dirty state stays in the baseline and never shows; a non-git
// workspace fails the first command and the sweep silently stays off.
package tui
import (
"context"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// gitSweepTimeout bounds each background git invocation so a hung index lock
// can never stall message delivery (the command runs off the update goroutine).
const gitSweepTimeout = 3 * time.Second
// gitSweepFile is one workspace file git reports as dirty: workspace-relative
// path (git's own output convention, matching changedFiles), whether it is new
// (untracked/added), and the tracked diffstat when known (0/0 for untracked).
type gitSweepFile struct {
path string
created bool
adds int
dels int
}
// gitSweepMsg carries one sweep's result. baseline marks the startup snapshot
// (recorded as "pre-existing, never show") vs a live re-check (merged into the
// roster). ok is false when git failed (not a repo, no git binary) — the
// handler then marks the sweep unavailable rather than retrying every turn.
type gitSweepMsg struct {
baseline bool
ok bool
files []gitSweepFile
}
// gitSweepCmd runs the status+numstat pair off the update goroutine. cwd is the
// workspace root the TUI resolves paths against.
func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
return func() tea.Msg {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, gitSweepTimeout)
defer cancel()
// --untracked-files=all enumerates files inside new directories; plain
// porcelain collapses them to "dir/", which is useless as a FILES row.
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
if err != nil {
return gitSweepMsg{baseline: baseline, ok: false}
}
files := parseGitPorcelain(status)
// Diffstat for tracked modifications; untracked files have no diff to
// stat. -z makes the output machine-parseable: NUL-terminated records, no
// path quoting, and renames as explicit preimage/postimage fields instead
// of the brace-mangled "src/{old => new}" a plain --numstat emits (which
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
// HEAD in a fresh repo) keeps the file list with zero counts rather than
// dropping the sweep.
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
stats := parseGitNumstat(numstat)
for i := range files {
if counts, ok := stats[files[i].path]; ok {
files[i].adds, files[i].dels = counts[0], counts[1]
}
}
}
return gitSweepMsg{baseline: baseline, ok: true, files: files}
}
}
// parseGitPorcelain parses `git status --porcelain` v1 output into sweep files.
// Format: two status columns, a space, then the path ("XY path"); renames show
// "old -> new" (keep the new side); untracked entries are "?? path".
func parseGitPorcelain(out string) []gitSweepFile {
var files []gitSweepFile
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 {
continue
}
code, path := line[:2], strings.TrimSpace(line[3:])
if to, _, found := cutRename(path); found {
path = to
}
path = unquoteGitPath(path)
if path == "" {
continue
}
files = append(files, gitSweepFile{
path: path,
created: code == "??" || strings.Contains(code, "A"),
})
}
return files
}
// cutRename splits a porcelain rename value "old -> new", returning the new
// path. found is false for ordinary (non-rename) paths.
func cutRename(path string) (string, string, bool) {
if idx := strings.Index(path, " -> "); idx >= 0 {
return path[idx+4:], path[:idx], true
}
return "", "", false
}
// unquoteGitPath strips the quotes git wraps around paths with special
// characters ("web/my file.js"). Escapes inside are left as-is — such a path
// still identifies the file well enough for a sidebar row.
func unquoteGitPath(path string) string {
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
return path[1 : len(path)-1]
}
return path
}
// parseGitNumstat parses `git diff --numstat -z` output into path → [added,
// deleted]. -z records are NUL-terminated "added\tdeleted\tpath"; a rename/copy
// instead ends the counts record with an EMPTY path ("added\tdeleted\t") and
// appends two more NUL-terminated fields, preimage then postimage — the stats
// key is the postimage (the path porcelain/changedFiles report). Paths are
// never quoted or brace-mangled in -z mode. Binary files report "-" counts and
// are skipped.
func parseGitNumstat(out string) map[string][2]int {
stats := map[string][2]int{}
fields := strings.Split(out, "\x00")
for i := 0; i < len(fields); i++ {
parts := strings.SplitN(fields[i], "\t", 3)
if len(parts) != 3 {
continue
}
adds, errA := strconv.Atoi(parts[0])
dels, errD := strconv.Atoi(parts[1])
if errA != nil || errD != nil {
continue
}
path := parts[2]
if path == "" {
// Rename/copy record: the next two fields are preimage, postimage.
if i+2 >= len(fields) {
break // truncated output — drop the partial record
}
path = fields[i+2]
i += 2
}
if path != "" {
stats[path] = [2]int{adds, dels}
}
}
return stats
}
// handleGitSweepMsg folds a sweep result into the model: the baseline snapshot
// records what was already dirty before this TUI session (those paths never
// show), a live sweep upserts newly dirty paths into gitTouched in first-seen
// order. A failed sweep marks git unavailable so no further sweeps are issued.
func (m model) handleGitSweepMsg(msg gitSweepMsg) model {
m.gitSweepInFlight = false
if !msg.ok {
m.gitSweepUnavailable = true
if m.gitFileBaseline == nil {
m.gitFileBaseline = map[string]bool{}
}
return m
}
if msg.baseline {
baseline := make(map[string]bool, len(msg.files))
for _, f := range msg.files {
baseline[f.path] = true
}
m.gitFileBaseline = baseline
return m
}
for _, f := range msg.files {
if m.gitFileBaseline[f.path] {
continue
}
found := false
for i := range m.gitTouched {
if m.gitTouched[i].path == f.path {
m.gitTouched[i] = f
found = true
break
}
}
if !found {
// Copy-on-append: model copies share the backing array, so an in-place
// append from two update branches could alias. Rebuilding is cheap at
// sidebar scale.
m.gitTouched = append(append([]gitSweepFile(nil), m.gitTouched...), f)
}
}
return m
}
// maybeGitSweep issues a live sweep when one is useful and none is running:
// the baseline exists (Init's snapshot answered), git works here, and the
// workspace is known. Returns the (possibly nil) command to batch.
func (m model) maybeGitSweep() (model, tea.Cmd) {
if m.gitSweepInFlight || m.gitSweepUnavailable || m.gitFileBaseline == nil || strings.TrimSpace(m.cwd) == "" {
return m, nil
}
m.gitSweepInFlight = true
return m, gitSweepCmd(m.ctx, m.cwd, false)
}
// gitTouchedFiles adapts the sweep results to the roster's touchedFile shape,
// for merging under the transcript-derived entries (files_panel.go), newest
// detection first — gitTouched upserts in first-seen order, so iterating it in
// reverse keeps the merged FILES tail consistent with the roster's
// newest-first ordering. No transcript row backs them (lastRowIndex -1), so
// selecting one skips the scroll/tint and the drill-in opens on the full file.
func (m model) gitTouchedFiles() []touchedFile {
if len(m.gitTouched) == 0 {
return nil
}
files := make([]touchedFile, 0, len(m.gitTouched))
for i := len(m.gitTouched) - 1; i >= 0; i-- {
f := m.gitTouched[i]
files = append(files, touchedFile{
path: f.path,
created: f.created,
adds: f.adds,
dels: f.dels,
lastRowIndex: -1,
})
}
return files
}
+243
View File
@@ -0,0 +1,243 @@
package tui
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestParseGitPorcelain(t *testing.T) {
out := " M web/app.js\n?? web/new.css\nA docs/added.md\nR old.go -> pkg/new.go\n?? \"web/my file.js\"\n\n"
files := parseGitPorcelain(out)
if len(files) != 5 {
t.Fatalf("expected 5 entries, got %d: %+v", len(files), files)
}
byPath := map[string]gitSweepFile{}
for _, f := range files {
byPath[f.path] = f
}
if f := byPath["web/app.js"]; f.created {
t.Error("modified file must not read as created")
}
if f := byPath["web/new.css"]; !f.created {
t.Error("untracked file should read as created")
}
if f := byPath["docs/added.md"]; !f.created {
t.Error("index-added file should read as created")
}
if _, ok := byPath["pkg/new.go"]; !ok {
t.Errorf("rename should keep the new path: %+v", files)
}
if _, ok := byPath["web/my file.js"]; !ok {
t.Errorf("quoted path should unquote: %+v", files)
}
}
// TestParseGitNumstat feeds the exact `git diff --numstat -z` record shapes:
// plain "added\tdeleted\tpath\0", binary "-\t-\tpath\0", and the rename form
// "added\tdeleted\t\0preimage\0postimage\0" — the stats must key the postimage
// (the path porcelain/changedFiles report), which the old newline parser lost
// (plain --numstat brace-mangles renames, so they never matched a roster path).
func TestParseGitNumstat(t *testing.T) {
out := "12\t3\tweb/app.js\x00" +
"-\t-\tassets/logo.png\x00" +
"4\t0\t\x00old.go\x00pkg/new.go\x00" +
"1\t1\tweb/my file.js\x00" // -z paths are never quoted
stats := parseGitNumstat(out)
if got := stats["web/app.js"]; got != [2]int{12, 3} {
t.Errorf("web/app.js = %v, want [12 3]", got)
}
if _, ok := stats["assets/logo.png"]; ok {
t.Error("binary '-' entries must be skipped")
}
if got := stats["pkg/new.go"]; got != [2]int{4, 0} {
t.Errorf("renamed file must key the postimage path = %v, want [4 0]", got)
}
if _, ok := stats["old.go"]; ok {
t.Error("the rename preimage must not appear as its own entry")
}
if got := stats["web/my file.js"]; got != [2]int{1, 1} {
t.Errorf("path with spaces = %v, want [1 1] (no quoting in -z mode)", got)
}
// Truncated rename record (missing postimage) must not panic or mis-key.
if got := parseGitNumstat("4\t0\t\x00old.go"); len(got) != 0 {
t.Errorf("truncated rename record should be dropped, got %v", got)
}
}
// TestGitSweepMergeAndBaseline: the baseline snapshot hides pre-existing dirty
// paths; live sweeps upsert only new ones; a failed sweep latches unavailable.
func TestGitSweepMergeAndBaseline(t *testing.T) {
m := model{}
m = m.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: true, files: []gitSweepFile{{path: "dirty-before.go"}}})
if !m.gitFileBaseline["dirty-before.go"] {
t.Fatal("baseline should record pre-existing dirty paths")
}
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{
{path: "dirty-before.go", adds: 9},
{path: "kanban/board.tsx", created: true, adds: 120},
}})
if len(m.gitTouched) != 1 || m.gitTouched[0].path != "kanban/board.tsx" {
t.Fatalf("only newly dirty paths should merge: %+v", m.gitTouched)
}
// Re-sweep updates stats in place, no duplicate.
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{{path: "kanban/board.tsx", created: true, adds: 150, dels: 2}}})
if len(m.gitTouched) != 1 || m.gitTouched[0].adds != 150 {
t.Fatalf("re-sweep should upsert stats: %+v", m.gitTouched)
}
failed := model{}
failed = failed.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: false})
if !failed.gitSweepUnavailable {
t.Fatal("a failed sweep should latch unavailable")
}
if _, cmd := failed.maybeGitSweep(); cmd != nil {
t.Fatal("no further sweeps once unavailable")
}
}
// TestMaybeGitSweepGating: no sweep before the baseline answers, none while one
// is in flight, and the in-flight flag sets when one is issued.
func TestMaybeGitSweepGating(t *testing.T) {
m := model{cwd: "/tmp"}
if _, cmd := m.maybeGitSweep(); cmd != nil {
t.Fatal("no baseline yet: sweep must not run")
}
m.gitFileBaseline = map[string]bool{}
next, cmd := m.maybeGitSweep()
if cmd == nil || !next.gitSweepInFlight {
t.Fatal("with a baseline, a sweep should be issued and marked in flight")
}
if _, again := next.maybeGitSweep(); again != nil {
t.Fatal("single-flight: no second sweep while one runs")
}
}
// TestTouchedFilesMergesGitSweep: git-discovered files append below the
// transcript-derived entries, deduped by path (a tool-result entry wins).
func TestTouchedFilesMergesGitSweep(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{
{path: "kanban/board.tsx", created: true, adds: 120},
{path: "web/app.js", adds: 999}, // duplicate of a transcript entry
}
files := m.touchedFiles()
var kanban *touchedFile
appCount := 0
for i := range files {
if files[i].path == "kanban/board.tsx" {
kanban = &files[i]
}
if files[i].path == "web/app.js" {
appCount++
if files[i].adds == 999 {
t.Error("transcript-derived entry should win over the git duplicate")
}
}
}
if kanban == nil || !kanban.created || kanban.lastRowIndex != -1 {
t.Fatalf("git-only file should merge with created badge and no row anchor: %+v", files)
}
if appCount != 1 {
t.Fatalf("web/app.js should appear exactly once, got %d", appCount)
}
}
// TestOpenFileViewGitOnlyFallsBackToFull: a file with no edit cards (git sweep
// discovery) opens straight on the full-file view instead of an empty diff.
func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) {
m := filesPanelTestModel()
m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}}
m = m.openFileView("kanban/board.tsx")
if m.fileView.mode != fileViewFull {
t.Fatal("git-only file should open in full mode")
}
m = m.exitFileView()
m = m.openFileView("web/app.js")
if m.fileView.mode != fileViewDiff {
t.Fatal("a file with edit cards still opens in diff mode")
}
}
// TestGitSweepCmdAgainstRealRepo: end-to-end against a real git repo — the
// baseline sees pre-existing dirt, a post-mutation sweep reports the new file
// as created and the modified file with its numstat.
func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
write := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
run("init", "-q")
write("tracked.txt", "one\ntwo\n")
run("add", ".")
run("commit", "-q", "-m", "seed")
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
}
// The "agent" now scaffolds via the shell.
write("scaffolded.txt", "hello\n")
write("tracked.txt", "one\ntwo\nthree\nfour\n")
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !sweep.ok {
t.Fatal("sweep failed")
}
byPath := map[string]gitSweepFile{}
for _, f := range sweep.files {
byPath[f.path] = f
}
if f, ok := byPath["scaffolded.txt"]; !ok || !f.created {
t.Fatalf("scaffolded file should report created: %+v", sweep.files)
}
if f := byPath["tracked.txt"]; f.adds != 2 || f.dels != 0 {
t.Fatalf("tracked.txt numstat = +%d −%d, want +2 −0", f.adds, f.dels)
}
// A rename WITH edits: porcelain reports the new path, and the -z numstat
// rename record must resolve to the same (postimage) path so the diffstat
// actually attaches — the plain --numstat form brace-mangles the path and
// the counts were silently lost.
run("add", ".")
run("commit", "-q", "-m", "second")
run("mv", "tracked.txt", "renamed.txt")
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
if !renameSweep.ok {
t.Fatal("rename sweep failed")
}
renamed := map[string]gitSweepFile{}
for _, f := range renameSweep.files {
renamed[f.path] = f
}
if f, ok := renamed["renamed.txt"]; !ok || f.adds != 1 {
t.Fatalf("renamed file should carry its numstat under the new path, got %+v", renameSweep.files)
}
// Not-a-repo → ok=false (the sweep latches off).
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
t.Fatal("a non-repo should report ok=false")
}
}
+416
View File
@@ -0,0 +1,416 @@
// files_panel.go renders the FILES section of the right context sidebar: the
// workspace files this session has touched, newest first, with an A/M badge and
// a +added/−removed diffstat per file, plus a pulsing row for the file whose
// write is streaming right now. Like the swarm roster (sidebar.go), the touched
// set is not separate model state — it is recovered on demand from the
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
package tui
import (
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// maxSidebarFiles caps the FILES rows so the section stays a glanceable set,
// not a scrolling log; older files collapse into a "+N more" trailer.
const maxSidebarFiles = 6
// touchedFile is one workspace file the session has mutated, aggregated across
// every tool-result row that touched it.
type touchedFile struct {
path string
adds int // total added lines across its diffs
dels int // total removed lines across its diffs
edits int // number of mutations
created bool // first touch created the file (write_file "Created …")
failed bool // the latest touch errored
lastRowIndex int // transcript index of the most recent result touching it
}
// touchedFiles recovers the session's touched-file roster from the transcript,
// most recently touched first. Recomputed on demand (the value-receiver model
// can't persist a registry from View), mirroring swarmSpawnedAgents; the scan is
// a single pass over the transcript per render, same order as the sidebar's
// other sections.
func (m model) touchedFiles() []touchedFile {
var files []touchedFile
index := map[string]int{}
for i, row := range m.transcript {
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
continue
}
adds, dels := planDiffStat(row.detail)
// A multi-file result (apply_patch spanning several files) must not charge
// the WHOLE patch's totals to every file it touched — split the diff by its
// per-file headers and attribute each file its own counts.
var perFile map[string][2]int
if len(row.changedFiles) > 1 {
perFile = perFileDiffStats(row.detail)
}
for _, path := range row.changedFiles {
if path == "" {
continue
}
at, seen := index[path]
if !seen {
index[path] = len(files)
files = append(files, touchedFile{
path: path,
created: resultRowCreatedFile(row),
lastRowIndex: i,
})
at = len(files) - 1
}
fileAdds, fileDels := adds, dels
if perFile != nil {
// Headers were found: use this file's own counts. A path the split
// couldn't attribute shows 0/0 rather than the inflated patch totals.
counts := perFile[path]
fileAdds, fileDels = counts[0], counts[1]
}
files[at].adds += fileAdds
files[at].dels += fileDels
files[at].edits++
files[at].failed = row.status == tools.StatusError
files[at].lastRowIndex = i
}
}
// Most recently touched first: the file being worked on now belongs at the
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
// first-seen order): a file touched early and touched again last must list
// first. Stable, so same-row files keep their changedFiles order.
sort.SliceStable(files, func(i, j int) bool {
return files[i].lastRowIndex > files[j].lastRowIndex
})
// Merge the git sweep's discoveries (bash/subagent mutations that carry no
// changedFiles — files_git_sweep.go) below the transcript-derived entries,
// skipping paths a tool result already reported.
for _, f := range m.gitTouchedFiles() {
if _, seen := index[f.path]; seen {
continue
}
files = append(files, f)
}
return files
}
// perFileDiffStats splits a (possibly multi-file) unified diff into per-path
// +added/−removed counts, keyed by the workspace-relative path from each
// section's "+++ b/…" header (the "--- a/…" old side when the new side is
// /dev/null, i.e. a deletion). Returns nil when the detail carries no file
// headers at all (write_file/edit_file previews may be bare hunks) — the caller
// then falls back to whole-detail totals, which are correct for a single file.
func perFileDiffStats(detail string) map[string][2]int {
stats := map[string][2]int{}
current, oldPath := "", ""
normalize := func(line string, prefix string) string {
path := strings.TrimSpace(line[4:])
if tab := strings.IndexByte(path, '\t'); tab >= 0 {
path = path[:tab] // unified-diff convention: optional timestamp after a tab
}
path = unquoteGitPath(path)
if path == "/dev/null" {
return ""
}
return strings.TrimPrefix(path, prefix)
}
for _, line := range strings.Split(detail, "\n") {
switch {
case strings.HasPrefix(line, "--- "):
oldPath = normalize(line, "a/")
case strings.HasPrefix(line, "+++ "):
current = normalize(line, "b/")
if current == "" {
current = oldPath // deletion: charge the removed lines to the old path
}
case current == "":
// Lines before the first header (or in an unattributable section).
case strings.HasPrefix(line, "+"):
counts := stats[current]
counts[0]++
stats[current] = counts
case strings.HasPrefix(line, "-"):
counts := stats[current]
counts[1]++
stats[current] = counts
}
}
if len(stats) == 0 {
return nil
}
return stats
}
// resultRowCreatedFile reports whether a tool-result row reads as a file
// creation ("tool result: write_file ok Created x (…)"), so the FILES row can
// badge it A rather than M. Heuristic on the confirmation text write_file
// already emits; a miss just shows M, never breaks anything.
func resultRowCreatedFile(row transcriptRow) bool {
return strings.Contains(row.text, " Created ")
}
// liveEditingPath is the path of the file whose mutating tool call is streaming
// its arguments RIGHT NOW (the same live write streamingToolCallView previews),
// or "" when no mutation is in flight. It renders as a pulsing row pinned atop
// the FILES section.
func (m model) liveEditingPath() string {
if m.streamCallDecoder == nil {
return ""
}
switch m.streamCallName {
case "write_file", "edit_file", "apply_patch":
return m.streamCallDecoder.path
}
return ""
}
// fileHit marks a rendered FILES row (by its ABSOLUTE index inside the rendered
// sidebar) that is clickable, carrying the file it refers to.
type fileHit struct {
lineOffset int
path string
}
// sidebarFilesHeader renders the FILES section header with the touched count.
func (m model) sidebarFilesHeader(width int) string {
n := len(m.touchedFiles())
if n == 0 {
return sidebarHeader("FILES", width)
}
return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width)
}
// sidebarFileLines renders the FILES section body: the live "writing" pulse row
// first, then the touched files newest-first, capped at maxSidebarFiles with a
// "+N more" trailer. Returns nil when the session has touched nothing (the
// caller shows a placeholder). The paired hits slice records each clickable
// row's index WITHIN the returned lines.
func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
files := m.touchedFiles()
live := m.liveEditingPath()
if len(files) == 0 && live == "" {
return nil, nil
}
room := maxInt(4, width-3)
var lines []string
var hits []fileHit
// The file being written this instant: an accent pulse, not yet clickable
// (its result row — the diff — doesn't exist until the call completes).
if live != "" {
lines = append(lines, " "+zeroTheme.accent.Render("●")+" "+
zeroTheme.ink.Render(truncatePathLeft(live, room)))
}
// Filter the live row out FIRST so the "+N more" trailer counts only rows
// that could actually render — counting the skipped live entry would inflate
// the overflow by one.
visible := files[:0:0]
for _, f := range files {
if f.path != live {
visible = append(visible, f)
}
}
shown := 0
for _, f := range visible {
if shown >= maxSidebarFiles {
lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("+%d more", len(visible)-shown)))
break
}
shown++
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
lines = append(lines, m.renderFileRow(f, room))
}
return lines, hits
}
// renderFileRow renders one touched file: status badge, left-truncated path,
// and the +/− diffstat, with the selected file carrying an accent marker so the
// selection reads in the sidebar as well as in the chat tint.
func (m model) renderFileRow(f touchedFile, room int) string {
badge := zeroTheme.muted.Render("M")
switch {
case f.failed:
badge = zeroTheme.red.Render("✗")
case f.created:
badge = zeroTheme.green.Render("A")
}
// Reserve the diffstat's width so the path truncates around it.
stat := ""
if f.adds > 0 || f.dels > 0 {
stat = fmt.Sprintf(" +%d −%d", f.adds, f.dels)
}
pathRoom := maxInt(4, room-len(stat))
pathStyle := zeroTheme.muted
lead := " "
if m.selectedFile == f.path {
lead = zeroTheme.accent.Render("▸")
pathStyle = zeroTheme.ink
}
line := lead + badge + " " + pathStyle.Render(truncatePathLeft(f.path, pathRoom))
if stat != "" {
line += zeroTheme.faintest.Render(stat)
}
return line
}
// sidebarFileSelectables returns the clickable FILES rows with their ABSOLUTE
// index inside the rendered sidebar. The FILES section renders after PLAN in
// renderContextSidebar: AGENTS header + body, blank + PLAN header + body, then
// blank + FILES header, then the file rows. The offset accounting mirrors
// sidebarPlanSelectables exactly, extended one section down.
func (m model) sidebarFileSelectables(width int) []fileHit {
lines, hits := m.sidebarFileLines(width)
if len(lines) == 0 {
return nil
}
agentBody := len(m.sidebarAgentLines(width))
if agentBody == 0 {
agentBody = 1 // the "no agents spawned" placeholder occupies one line
}
planBody := len(m.sidebarPlanLines(width))
if planBody == 0 {
planBody = 1 // the "no active plan" placeholder occupies one line
}
base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header)
for i := range hits {
hits[i].lineOffset += base
}
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
if m.selectedFile == "" || row.kind != rowToolResult {
return false
}
for _, path := range row.changedFiles {
if path == m.selectedFile {
return true
}
}
return false
}
// lastRowIndexForFile returns the transcript index of the most recent
// tool-result row that touched path, or -1.
func (m model) lastRowIndexForFile(path string) int {
for i := len(m.transcript) - 1; i >= 0; i-- {
row := m.transcript[i]
if row.kind != rowToolResult {
continue
}
for _, p := range row.changedFiles {
if p == path {
return i
}
}
}
return -1
}
// selectFile marks path as the selected file and scrolls the transcript so its
// most recent edit card is in view; the card tint comes from the renderers
// reading selectedFile (rowTouchesSelectedFile).
func (m model) selectFile(path string) model {
m.selectedFile = path
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
m.chatScrollOffset = offset
if offset == 0 {
m.chatBodyLines = 0
}
}
return m
}
// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the
// given transcript row's first rendered line at the top of the viewport (with
// one line of breathing room), using the same layout metrics the scroll engine
// itself uses. ok is false outside alt-screen or when the row isn't rendered
// (e.g. skipped/collapsed).
func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
if rowIndex < 0 || !m.altScreen || m.height <= 0 {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
if span.kind == transcriptBodyItemRow && span.rowIndex == rowIndex {
startY = span.startY
break
}
}
if startY < 0 {
return 0, false
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
viewport := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset)
// The offset counts lines below the fold: window.start == total-height-offset,
// so placing startY at the window top (minus one context line) means
// offset = total - height - (startY - 1), clamped to the scroll range.
offset := metrics.totalLines() - frame.bodyRect.height - (startY - 1)
return clampInt(offset, 0, viewport.maxOffset()), true
}
// truncatePathLeft fits a path into room cells by dropping leading components
// ("internal/tui/sidebar.go" → "…/tui/sidebar.go"), keeping the filename — the
// part that identifies the file — intact for as long as possible.
func truncatePathLeft(path string, room int) string {
if room <= 0 {
return ""
}
if len(path) <= room {
return path
}
const ellipsis = "…/"
parts := strings.Split(path, "/")
for start := 1; start < len(parts); start++ {
candidate := ellipsis + strings.Join(parts[start:], "/")
if len(candidate) <= room {
return candidate
}
}
// Even "…/name" overflows: fall back to a right-truncated basename.
return truncateRunes(ellipsis+parts[len(parts)-1], room)
}
+317
View File
@@ -0,0 +1,317 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// filesPanelTestModel is sidebarTestModel plus a couple of touched files: one
// created, one edited twice (the second touch most recent).
func filesPanelTestModel() model {
m := sidebarTestModel()
m.transcript = append(m.transcript,
transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1", status: tools.StatusOK,
text: "tool result: write_file ok Created web/app.js (10 lines).",
detail: "+let a = 1\n+let b = 2",
changedFiles: []string{"web/app.js"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f2", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+added one\n-removed one",
changedFiles: []string{"internal/tui/sidebar.go"}},
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f3", status: tools.StatusOK,
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
detail: "+two\n+three\n-gone",
changedFiles: []string{"internal/tui/sidebar.go"}},
)
return m
}
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
// newest-touch first, with per-file diffstat totals, edit counts, and the
// created badge from write_file's confirmation text.
func TestTouchedFilesAggregates(t *testing.T) {
m := filesPanelTestModel()
files := m.touchedFiles()
if len(files) != 2 {
t.Fatalf("expected 2 touched files, got %d: %+v", len(files), files)
}
// sidebar.go was touched last, so it lists first.
if files[0].path != "internal/tui/sidebar.go" {
t.Fatalf("most recently touched file should list first, got %q", files[0].path)
}
if files[0].adds != 3 || files[0].dels != 2 || files[0].edits != 2 {
t.Errorf("sidebar.go diffstat = +%d −%d over %d edits, want +3 −2 over 2", files[0].adds, files[0].dels, files[0].edits)
}
if files[0].created {
t.Error("edited file must not read as created")
}
if !files[1].created {
t.Error("write_file 'Created …' result should mark the file created")
}
if files[0].lastRowIndex != len(m.transcript)-1 {
t.Errorf("lastRowIndex = %d, want the final transcript row", files[0].lastRowIndex)
}
}
// TestSidebarFileLinesRenderAndOverflow: rows carry the badge + left-truncated
// path + diffstat; beyond maxSidebarFiles the tail collapses into "+N more";
// hits index only real file rows.
func TestSidebarFileLinesRenderAndOverflow(t *testing.T) {
m := filesPanelTestModel()
lines, hits := m.sidebarFileLines(34)
joined := plainRender(t, strings.Join(lines, "\n"))
if !strings.Contains(joined, "sidebar.go") || !strings.Contains(joined, "app.js") {
t.Fatalf("file rows missing paths:\n%s", joined)
}
if !strings.Contains(joined, "+3 −2") {
t.Errorf("expected aggregated diffstat +3 −2:\n%s", joined)
}
if !strings.Contains(joined, "A") || !strings.Contains(joined, "M") {
t.Errorf("expected A and M badges:\n%s", joined)
}
if len(hits) != 2 {
t.Fatalf("expected 2 clickable hits, got %d", len(hits))
}
if hits[0].path != "internal/tui/sidebar.go" {
t.Errorf("first hit should be the newest file, got %q", hits[0].path)
}
// Overflow: 8 distinct files -> maxSidebarFiles rows + a "+2 more" trailer.
over := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
over.transcript = append(over.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
overLines, overHits := over.sidebarFileLines(34)
if len(overHits) != maxSidebarFiles {
t.Fatalf("expected %d clickable rows, got %d", maxSidebarFiles, len(overHits))
}
if got := plainRender(t, overLines[len(overLines)-1]); !strings.Contains(got, "+2 more") {
t.Errorf("expected '+2 more' trailer, got %q", got)
}
}
// TestSidebarFilesLivePulseRow: while a mutating tool call streams its args,
// the file it is writing shows as a pinned (unclickable) pulse row.
func TestSidebarFilesLivePulseRow(t *testing.T) {
m := filesPanelTestModel()
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
lines, hits := m.sidebarFileLines(34)
if got := plainRender(t, lines[0]); !strings.Contains(got, "web/new.css") {
t.Fatalf("live write should pin its path atop FILES, got %q", got)
}
for _, hit := range hits {
if hit.path == "web/new.css" {
t.Error("the live row must not be clickable (no result exists yet)")
}
}
// A non-mutating streaming call shows no pulse row.
m.streamCallName = "read_file"
if m.liveEditingPath() != "" {
t.Error("read_file must not read as a live edit")
}
}
// TestSidebarFileSelectablesMatchRenderedRows: the hit offsets computed by
// sidebarFileSelectables must land exactly on the rendered FILES rows in
// renderContextSidebar's output — the same invariant the mouse hit-test relies
// on. Asserted against the real render, not a re-derivation of the math.
func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
rendered := m.renderContextSidebar(width, m.height)
hits := m.sidebarFileSelectables(width)
if len(hits) == 0 {
t.Fatal("expected clickable FILES rows")
}
for _, hit := range hits {
if hit.lineOffset >= len(rendered) {
t.Fatalf("hit offset %d beyond rendered sidebar (%d lines)", hit.lineOffset, len(rendered))
}
line := plainRender(t, rendered[hit.lineOffset])
base := hit.path[strings.LastIndex(hit.path, "/")+1:]
if !strings.Contains(line, base) {
t.Errorf("hit for %q points at line %d which reads %q", hit.path, hit.lineOffset, line)
}
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
}
}
// TestRowTouchesSelectedFileTint: only tool-result rows whose changedFiles
// carry the selected path tint, and the render cache key differs across
// selection states so the tint can't be served stale.
func TestRowTouchesSelectedFileTint(t *testing.T) {
m := filesPanelTestModel()
row := m.transcript[len(m.transcript)-1]
if m.rowTouchesSelectedFile(row) {
t.Fatal("nothing selected: no row should tint")
}
m.selectedFile = "internal/tui/sidebar.go"
if !m.rowTouchesSelectedFile(row) {
t.Fatal("selected file's edit row should tint")
}
other := m.transcript[len(m.transcript)-3] // web/app.js
if m.rowTouchesSelectedFile(other) {
t.Fatal("an unrelated file's row must not tint")
}
rc := buildRowContext(m.transcript)
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
selectedKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
m.selectedFile = ""
plainKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
if selectedKey == plainKey {
t.Error("render cache key must change with the selection state")
}
}
// TestEscClearsFileSelectionThenNothing: Esc drops an active file selection
// (before any run-level action) and is consumed doing so.
func TestEscClearsFileSelectionThenNothing(t *testing.T) {
m := filesPanelTestModel()
m.selectedFile = "web/app.js"
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if got := updated.(model).selectedFile; got != "" {
t.Fatalf("Esc should clear the file selection, still %q", got)
}
}
func TestTruncatePathLeft(t *testing.T) {
if got := truncatePathLeft("internal/tui/sidebar.go", 40); got != "internal/tui/sidebar.go" {
t.Errorf("short path should pass through, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 18); got != "…/tui/sidebar.go" {
t.Errorf("want …/tui/sidebar.go, got %q", got)
}
if got := truncatePathLeft("internal/tui/sidebar.go", 14); got != "…/sidebar.go" {
t.Errorf("want …/sidebar.go, got %q", got)
}
}
// TestTouchedFilesPerFileDiffStat: a multi-file result (apply_patch spanning
// several files) must charge each file its OWN +/- counts from the per-file
// diff sections — not the whole patch's totals to every file it touched.
func TestTouchedFilesPerFileDiffStat(t *testing.T) {
m := sidebarTestModel()
patch := "diff --git a/web/one.js b/web/one.js\n" +
"--- a/web/one.js\n+++ b/web/one.js\n@@ -1,1 +1,2 @@\n+added one\n+added two\n" +
"diff --git a/web/two.js b/web/two.js\n" +
"--- a/web/two.js\n+++ b/web/two.js\n@@ -1,2 +1,1 @@\n-gone one\n-gone two\n-gone three\n"
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "apply_patch", id: "p1", status: tools.StatusOK,
detail: patch,
changedFiles: []string{"web/one.js", "web/two.js"},
})
byPath := map[string]touchedFile{}
for _, f := range m.touchedFiles() {
byPath[f.path] = f
}
if f := byPath["web/one.js"]; f.adds != 2 || f.dels != 0 {
t.Errorf("web/one.js = +%d −%d, want its own +2 −0 (not the patch totals)", f.adds, f.dels)
}
if f := byPath["web/two.js"]; f.adds != 0 || f.dels != 3 {
t.Errorf("web/two.js = +%d −%d, want its own +0 −3 (not the patch totals)", f.adds, f.dels)
}
}
// TestTouchedFilesRetouchOrdersByLastTouch: touch a, then b, then a again — a
// must list first. The old "reverse first-seen order" put b first because a's
// slot kept its original position.
func TestTouchedFilesRetouchOrdersByLastTouch(t *testing.T) {
m := sidebarTestModel()
for _, step := range []struct{ id, path string }{
{"r1", "a.go"}, {"r2", "b.go"}, {"r3", "a.go"},
} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: step.id, status: tools.StatusOK,
changedFiles: []string{step.path},
})
}
files := m.touchedFiles()
if len(files) != 2 || files[0].path != "a.go" {
t.Fatalf("the re-touched file must list first, got %+v", files)
}
}
// TestSidebarFileLinesOverflowExcludesLiveRow: when the live-writing file is
// also a touched file, it renders as the pulse row and is skipped from the
// list — the "+N more" trailer must not count that skipped entry.
func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) {
m := sidebarTestModel()
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
m.transcript = append(m.transcript, transcriptRow{
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
changedFiles: []string{name + ".go"},
})
}
// h.go (a touched file) is ALSO the live write: 7 listable files remain,
// 6 render, so the trailer is +1 — not +2 (which counted the skipped live row).
m.streamCallName = "edit_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"h.go","content":"x`)
lines, _ := m.sidebarFileLines(34)
trailer := plainRender(t, lines[len(lines)-1])
if !strings.Contains(trailer, "+1 more") {
t.Fatalf("overflow must exclude the live row from the count, got %q", trailer)
}
}
// TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first
// write as content, so the FILES pulse is visible before any result row lands.
func TestSidebarHasContentForLiveWrite(t *testing.T) {
m := sidebarTestModel()
m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now
if m.sidebarHasContent() {
t.Fatal("session without agents/plan/files should have no sidebar content")
}
m.streamCallName = "write_file"
m.streamCallDecoder = newStreamingDecoder()
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
if !m.sidebarHasContent() {
t.Fatal("a live in-flight write must count as sidebar content")
}
}
+7
View File
@@ -15,6 +15,9 @@ const (
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
// step index.
hoverPlanStep
// hoverFileRow: a touched-file row in the sidebar's FILES section, identified
// by path.
hoverFileRow
)
// hoverTarget identifies the single clickable row (if any) currently under the
@@ -34,6 +37,7 @@ type hoverTarget struct {
bodyY int // hoverTranscript
sessionID string // hoverSidebarAgent
stepIndex int // hoverPlanStep
filePath string // hoverFileRow
}
// mouseHover reports whether msg is a plain cursor-movement event with NO button
@@ -59,6 +63,9 @@ func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"context"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
// delivered automatically on program start, but that's the terminal
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
// back to the unpadded, non-fullscreen render path for the whole session (the
// alt-screen viewport never gets filled below the actual content). Init must
// explicitly request it too, the same way it already explicitly requests the
// background color rather than relying solely on an unprompted push.
func TestInitRequestsWindowSize(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
cmd := m.Init()
if cmd == nil {
t.Fatal("Init() must return a non-nil command")
}
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
if !batchContainsCmd(t, cmd, want) {
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
}
}
// batchContainsCmd reports whether cmd (Init()'s single top-level
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
// (the same technique TestThemeAutoReProbesBackground uses for
// tea.RequestBackgroundColor — reliable for named top-level functions like
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
// them, since Init()'s batch also carries real closures (e.g. provider model
// discovery) that make live network calls and must not fire in a unit test.
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
t.Helper()
if cmd == nil {
return false
}
if reflect.ValueOf(cmd).Pointer() == want {
return true
}
batch, ok := cmd().(tea.BatchMsg)
if !ok {
return false
}
for _, sub := range batch {
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
return true
}
}
return false
}
+108 -12
View File
@@ -25,6 +25,7 @@ import (
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
@@ -210,6 +211,22 @@ type model struct {
// sidebar; when set, the chat reflows to full width. Distinct from the
// availability conditions in sidebarAvailable (geometry / mode / overlays).
sidebarHidden bool
// selectedFile is the touched file selected by clicking its FILES sidebar
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
selectedFile string
// fileView is the drill-in view for a touched file (file_view.go): while
// active the chat column's body shows the file's diff/content instead of the
// transcript, mirroring the subchat drill-in.
fileView fileViewState
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
// paths (nil until Init's sweep answers), the newly dirty files discovered by
// live sweeps (bash/subagent mutations that carry no changedFiles), the
// single-flight guard, and the "not a git repo / no git" latch.
gitFileBaseline map[string]bool
gitTouched []gitSweepFile
gitSweepInFlight bool
gitSweepUnavailable bool
// swarmDoneAt records when each swarm member was first seen finished (done/
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
@@ -790,6 +807,24 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
// nothing else ever asks again: m.height stays its zero value forever,
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
// unpadded, non-fullscreen render path for the rest of the session, and
// the alt-screen viewport never gets filled below the actual content.
// Explicitly requesting it here means Zero doesn't depend solely on the
// terminal's unprompted push — mirrors the RequestBackgroundColor request
// below for the same reason.
cmds = append(cmds, tea.RequestWindowSize)
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
// dirty when the TUI opens is pre-existing state, not this session's work
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
if strings.TrimSpace(m.cwd) != "" {
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
}
// In auto mode, ask the terminal for its background color; the reply arrives
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
if m.themeMode == themeAuto {
@@ -1045,6 +1080,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleCtrlC()
case keyCtrl(msg, 'o'):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
// (so mid-sentence typing is never hijacked) and no modal is up (so a
// permission prompt / ask-user / wizard keeps its own key handling).
if keyText(msg) == "f" {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case keyCtrl(msg, 'e'):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection).
@@ -1069,6 +1112,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
// File drill-in exits on Esc (returns to the chat at its saved scroll
// position); the file stays selected so a second Esc clears that. Only
// with no blocking modal up: Esc on a permission prompt / ask-user /
// wizard must reach THAT surface's deny/cancel handling below, not
// silently close the drill-in behind it.
if m.fileView.active && m.noBlockingModal() {
return m.exitFileView(), nil
}
if m.mcpCommandCancel != nil {
m.cancelMCPCommand()
if m.mcpAddWizard != nil {
@@ -1125,6 +1176,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.suggestionsActive() {
return m.dismissSuggestions(), nil
}
// A selected FILES row clears before anything run-related: the
// selection is a passive highlight, so Esc dropping it is cheap and
// expected (mirrors how editors clear selection on Esc).
if m.selectedFile != "" {
m.selectedFile = ""
return m, nil
}
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
@@ -1799,8 +1857,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
}
// End-of-turn git sweep: catch file mutations the tool stream couldn't
// report (bash scaffolding, subagent edits) so the FILES sidebar is
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, queuedCmd)
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -1951,6 +2014,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
m.transcript = appendTranscriptRow(m.transcript, msg.row)
m = m.captureStepWork(msg.row)
// A finished command tool may have mutated files git can see but no
// changedFiles reports (npm create, heredoc writes, subagent edits) —
// re-sweep so the FILES sidebar picks them up mid-turn.
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
var sweep tea.Cmd
m, sweep = m.maybeGitSweep()
return m, sweep
}
return m, nil
case swarmSessionsMsg:
// Merge completed swarm members' session ids so their AGENTS sidebar rows
@@ -1981,6 +2052,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
case prStateMsg:
m.prState = msg.state
return m, nil
case gitSweepMsg:
return m.handleGitSweepMsg(msg), nil
case prWatcherStartedMsg:
if msg.stop == nil {
return m, nil
@@ -2185,6 +2258,12 @@ func (m model) pinnedTitleBar(width int) string {
if !m.altScreen || m.height <= 0 {
return ""
}
// The file drill-in replaces the title bar with its nav line (path + key
// hints). Both are exactly one line, and every frame computation routes
// through here, so the swap never desyncs the viewport geometry.
if m.fileView.active {
return m.fileViewNavBar(width)
}
return m.titleBar(width)
}
@@ -2222,15 +2301,18 @@ func (m model) footerView(width int) string {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
footer.WriteString("\n")
// A message typed while a run is active is queued for the next turn; show its
// preview directly ABOVE the input box (not below), so it reads as "waiting to
// send" sitting on top of what you're currently typing.
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString(queued)
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if hint := m.composerDescriptionHint(width); hint != "" {
footer.WriteString("\n")
footer.WriteString(hint)
}
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString("\n")
footer.WriteString(queued)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
@@ -2806,6 +2888,15 @@ const quietWorkingHint = 8 * time.Second
// quietGenerationHint returns a "still generating…" cue with an advancing
// quiet-timer when the active turn has produced no streamed output for a while,
// else "". The advancing number is itself the liveness signal.
//
// Past half the provider's idle timeout, the cue escalates to name what's
// actually happening and when Zero will act on its own: a heartbeating-but-
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
// providerio.ContentStallTimeout(idle), but until it fires this exact same
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
return ""
@@ -2821,6 +2912,10 @@ func (m model) quietGenerationHint() string {
if quiet < quietWorkingHint {
return ""
}
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
ceiling := providerio.ContentStallTimeout(idleTimeout)
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
}
return "still generating… " + formatWorkingElapsed(quiet)
}
@@ -4269,13 +4364,14 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
}
}
row := transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
}
// A Task result is shown by the specialist card, and update_plan by the
// plan panel/sidebar, so skip both redundant transcript rows.
+32
View File
@@ -55,6 +55,38 @@ func TestQueuedPromptPreviewAppearsInView(t *testing.T) {
}
}
// TestQueuedPromptPreviewSitsAboveComposer: the queued-message preview must
// render ABOVE the input box, not below it — a message waiting to send should
// sit on top of what you're currently typing.
func TestQueuedPromptPreviewSitsAboveComposer(t *testing.T) {
m := newQueuedMessageTestModel(t)
m.pending = true
m.activeRunID = 1
m.runID = 1
m.width = 96
m.input.SetValue("summarize the failing test output")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
footer := plainRender(t, next.footerView(96))
lines := strings.Split(footer, "\n")
queuedLine, composerTopLine := -1, -1
for i, ln := range lines {
if queuedLine < 0 && strings.Contains(ln, "queued") {
queuedLine = i
}
if composerTopLine < 0 && strings.Contains(ln, "╭") { // composer box top border
composerTopLine = i
}
}
if queuedLine < 0 || composerTopLine < 0 {
t.Fatalf("expected both a queued line and a composer box; queued=%d composer=%d\n%s", queuedLine, composerTopLine, footer)
}
if queuedLine >= composerTopLine {
t.Fatalf("queued preview (line %d) must sit ABOVE the composer box top border (line %d):\n%s", queuedLine, composerTopLine, footer)
}
}
func TestAgentResponseLaunchesQueuedPrompt(t *testing.T) {
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "queued answer"},
+3
View File
@@ -164,6 +164,9 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
appendRenderCacheField(&b, strconv.FormatBool(row.final))
appendRenderCacheField(&b, strconv.Itoa(row.turnTools))
appendRenderCacheField(&b, strconv.FormatInt(int64(row.turnElapsed), 10))
// The FILES selection tints this row's card border, so selecting/deselecting
// a file must miss the cache entry rendered under the other state.
appendRenderCacheField(&b, strconv.FormatBool(m.rowTouchesSelectedFile(row)))
key := rcKey(row.runID, row.id)
appendRenderCacheField(&b, rc.hints[key])
+13
View File
@@ -167,6 +167,10 @@ type cardRenderOptions struct {
bodyCap int
cwd string
expanded bool
// fileSelected marks a tool-result card whose mutation touched the file
// selected in the FILES sidebar; the card border tints accent so the
// selection reads in the transcript.
fileSelected bool
}
// flushCardBodyMaxLines is the body cap for cards flushed to scrollback. The
@@ -210,6 +214,10 @@ func (m model) renderRowMode(row transcriptRow, width int, rc rowContext, flush
}
func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
// Resolved per-row (not at the opts construction sites) so every render
// path — live, flush, detailed — carries the FILES selection tint; the
// cache key includes the same predicate (renderRowCacheKey).
opts.fileSelected = m.rowTouchesSelectedFile(row)
switch row.kind {
case rowUser:
return renderUserRow(row, width)
@@ -1390,6 +1398,11 @@ func renderToolResultCard(row transcriptRow, width int, rc rowContext, opts card
glyph := zeroTheme.green.Render("•")
nameStyle := zeroTheme.green
borderStyle := zeroTheme.line
if opts.fileSelected {
// The selected FILES row's edit card: accent border, same as the
// sidebar's ▸ marker, so click → highlight reads as one gesture.
borderStyle = zeroTheme.accent
}
if failed {
glyph = zeroTheme.red.Render("•")
nameStyle = zeroTheme.red
+29 -6
View File
@@ -444,12 +444,13 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
}
output := payloadString(payload, "output")
rows = append(rows, transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
changedFiles: payloadStringSlice(payload, "changedFiles"),
})
case sessions.EventError:
if message := payloadString(payload, "message"); message != "" {
@@ -619,6 +620,28 @@ func payloadString(payload map[string]any, key string) string {
}
}
// payloadStringSlice reads a []string persisted into a session payload (JSON
// round-trips it as []any), skipping non-string entries. Nil when absent.
func payloadStringSlice(payload map[string]any, key string) []string {
switch typed := payload[key].(type) {
case []string:
return typed
case []any:
out := make([]string, 0, len(typed))
for _, v := range typed {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
default:
return nil
}
}
func payloadBool(payload map[string]any, key string) bool {
value := payload[key]
switch typed := value.(type) {
+24
View File
@@ -97,6 +97,11 @@ func (m model) sidebarHasContent() bool {
if len(m.sidebarSpecialists()) > 0 || len(m.swarmSpawnedAgents()) > 0 {
return true
}
if len(m.touchedFiles()) > 0 || m.liveEditingPath() != "" {
// A live in-flight write counts before its result row exists, so the
// FILES pulse for the session's first mutation isn't hidden.
return true
}
return !m.plan.isEmpty()
}
@@ -587,6 +592,19 @@ func (m model) renderContextSidebar(width, height int) []string {
lines = append(lines, planLines...)
}
// FILES section: the files this session has touched (files_panel.go).
// Rendered BELOW the plan steps so it never shifts sidebarPlanSelectables'
// click offsets; its own hits (sidebarFileSelectables) account for the
// sections above it.
add("")
add(m.sidebarFilesHeader(width))
fileLines, _ := m.sidebarFileLines(width)
if len(fileLines) == 0 {
add(sidebarPlaceholder("no files touched", width))
} else {
lines = append(lines, fileLines...)
}
// ACTIVITY section: recent completed work + a live "generating…" pulse. Shown
// BELOW the plan steps so it never shifts sidebarPlanSelectables' click offsets,
// and budgeted (height-1 minus what's used) so it clips ITSELF from the bottom
@@ -651,6 +669,12 @@ func (m model) hoveredSidebarLineOffset(width int) (int, bool) {
return hit.lineOffset, true
}
}
case hoverFileRow:
for _, hit := range m.sidebarFileSelectables(width) {
if hit.path == m.hover.filePath {
return hit.lineOffset, true
}
}
}
return 0, false
}
+5
View File
@@ -41,6 +41,11 @@ type transcriptRow struct {
askUser *agent.AskUserRequest
expanded bool // collapsible transcript rows, e.g. provider thoughts
// changedFiles lists the workspace-relative paths a mutating tool result
// wrote (from tools.Result.ChangedFiles; restored from the session payload on
// resume). The sidebar FILES section derives its roster from these.
changedFiles []string
// specialistInfo holds the specialist card data for rowSpecialist rows.
// Nil for all other row kinds.
specialistInfo *specialistInfo
+23
View File
@@ -211,6 +211,12 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele
}
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
// File drill-in: the chat column's body swaps to the viewed file's
// diff/content. Swapping HERE (the single source every consumer reads) keeps
// the viewport, scroll engine, renderer, and mouse hit-tests consistent.
if m.fileView.active {
return m.fileViewBodyItems(width)
}
items := []transcriptBodyItem{}
// Transcript ROWS render at the full chat width; row/status glyphs provide
// structure without adding another body margin. Block items (title bar, empty
@@ -1242,6 +1248,9 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
// session, reusing the specialist-card subchat path. Checked before the
// transcript hit-test since the sidebar is outside the chat column.
if hit, ok := m.sidebarLineAtMouse(msg); ok {
// The subchat drill-in owns the whole (single-column) view; a file
// drill-in can't meaningfully stay open behind it.
m = m.exitFileView()
if errMsg := m.subchat.enter(m.sessionStore, hit.sessionID, hit.title, m.chatScrollOffset); errMsg != "" {
m = m.appendSystemNotice(errMsg)
}
@@ -1252,10 +1261,24 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
// A click on a PLAN step row drops a transcript card listing the file
// changes captured while that step was in progress.
if stepIndex, ok := m.planStepAtMouse(msg); ok {
// The card lands in the chat transcript; close the file drill-in so
// it isn't appended invisibly behind the swapped body.
m = m.exitFileView()
var cmd tea.Cmd
m, cmd = m.openPlanStepDetail(stepIndex)
return m, cmd, true
}
// A click on a FILES row: first click selects the file (its edit cards
// tint and the chat scrolls to the most recent one); a click on the
// already-selected file — or any FILES click while the drill-in is open —
// opens/switches the file view.
if path, ok := m.fileRowAtMouse(msg); ok {
if m.fileView.active || m.selectedFile == path {
m.selectedFile = path
return m.openFileView(path), nil, true
}
return m.selectFile(path), nil, true
}
line, ok := m.transcriptLineAtMouse(msg)
if !ok {
if m.transcriptSelection.active {
+40
View File
@@ -106,6 +106,46 @@ func TestQuietGenerationHint(t *testing.T) {
}
}
// TestQuietGenerationHintEscalatesPastHalfIdleTimeout: a heartbeating-but-
// silent stream (chatgpt/gpt-5.x, ollama reasoning models — see
// providerio.ErrStreamStalled) and a genuinely healthy-but-slow one look
// identical under the plain "still generating… Xs" cue — the ticking number
// is the only signal, whether real content is still coming or nothing ever
// will. Past half the provider's idle timeout the cue must say so explicitly
// and name when Zero's own content-stall watchdog will act, rather than
// leaving the user to guess whether this is a hang.
func TestQuietGenerationHintEscalatesPastHalfIdleTimeout(t *testing.T) {
// 30s idle timeout: half (15s) sits comfortably above quietWorkingHint (8s),
// leaving a clean window to observe the plain cue before it escalates.
t.Setenv("ZERO_STREAM_IDLE_TIMEOUT", "30s")
base := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
m := model{now: func() time.Time { return base }}
m.activeRunID = 7
m.turnStartedAt = base
// Quiet past quietWorkingHint but under half the (30s) idle timeout -> the
// plain cue, same as TestQuietGenerationHint's normal case.
m.lastStreamActivity = base.Add(-10 * time.Second)
got := m.quietGenerationHint()
if !strings.Contains(got, "still generating") {
t.Fatalf("under half the idle timeout: want the plain cue, got %q", got)
}
if strings.Contains(got, "auto-recover") {
t.Fatalf("under half the idle timeout: should not have escalated yet, got %q", got)
}
// Quiet for >= half the idle timeout -> escalates to name the watchdog and
// its ceiling (providerio.ContentStallTimeout(30s) = 30s*1.2 = 36s here).
m.lastStreamActivity = base.Add(-15 * time.Second)
got = m.quietGenerationHint()
if !strings.Contains(got, "unusually quiet") || !strings.Contains(got, "auto-recover") {
t.Fatalf("past half the idle timeout: want an escalated cue naming the watchdog, got %q", got)
}
if !strings.Contains(got, "36s") {
t.Fatalf("escalated cue should name the content-stall ceiling (ContentStallTimeout(30s) = 36s), got %q", got)
}
}
// TestQuietHintHiddenWhenSidebarShowsIt: when the context sidebar is up (it
// carries the "generating…" pulse in ACTIVITY), the working line must NOT also
// show the hint — it appears in exactly one place.