Files
zero/internal/tui/plan_panel_progress_test.go
KRATOS 0e7318ff72 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315)
* fix(tui): plan panel reflects live progress + prompt cadence

The plan panel could read as stuck mid-task even while work continued. Three
fixes:
- Header showed steps[0] forever, so the title never advanced as the plan
  progressed; show the current step (in_progress, else first incomplete).
- updateFromItems matched steps by content only, so an in-place reword reset a
  step's elapsed timer to zero; add positional carry-over when the step count is
  unchanged.
- Prompt: tell the model to mark each step completed + the next in_progress
  before starting it, so the plan tracks real progress instead of jumping at the
  end.

Regression tests included.

* feat(tui): click a plan step to see what was built for it

Each plan step now records the file mutations (write_file/edit_file/apply_patch)
made while it was in_progress, captured from tool-result rows. Clicking a step
row in the context sidebar drops a transcript card listing those changes — the
implementation for that step. Step->line mapping mirrors sidebarAgentSelectables'
offset accounting (one line per step); capture is keyed to the active step and
cleared per run. Read-only and additive. Tests cover the offset math + capture.

* feat(tui): plan-step detail shows diffs + commands

Extend the clickable plan-step detail: capture bash/exec_command runs in addition
to file mutations, store each tool's full output (the diff for edit/apply_patch,
stdout/stderr for commands), and render the card grouped into Changes and
Commands with a short truncated excerpt of each item's diff/output.

* fix(tui): plan-step detail toggles instead of stacking duplicates

Re-clicking a plan step now hides its card (a stable transcript id + drop-by-id),
and clicking a different step replaces it, so at most one detail card is shown.
Previously each click appended a fresh card and they piled up.

* feat(tui): elaborate plan-step detail by status (what we did / will do)

* feat(tui): plan-step card leads with a prose explanation (agent's own narration)

* feat(tui): write plan-step explanations with the model on click

Clicking a PLAN step now shows a fresh, plain-English write-up of that
step, generated on demand by the active model:

- past tense ('what we did') for completed/failed steps, future tense
  ('what we'll do') for pending/in_progress steps
- immediate feedback: the card drops in showing 'Writing explanation…'
  and updates in place (by stable row id) when the text returns
- cached per step (content+status) so re-clicking is instant with no
  second model call; re-clicking still toggles the card closed
- the prompt is built from already-captured local data (step text,
  status, notes, plus a compact digest of file edits, commands, and the
  agent's narration) and asks for short, non-technical prose
- reuses the TUI's existing provider via a one-shot StreamCompletion;
  no new client, no hardcoded provider. Falls back to the local summary
  when no provider is configured or the request fails.

* fix(tui): minimal plan-step card styling (dim border, status-tinted title)

* feat(tui): auto-hide context sidebar when it has no agents or plan

* feat(tui): widen chat reading column to fill wide terminals with breathing room

* fix(tui): toggle context sidebar silently (no transcript notice)

* fix(tui): mark plan complete when a finished task forgot the final update_plan

* fix(tui): remove the lime streaming-text glow (render responses in static ink)

* fix(tui): remove the shaded panel band behind tool-result cards

* fix(tui): Ctrl+B hides the plan entirely (no pinned-panel fallback)

* style(tui): gofmt-align model struct fields

* feat: narrate the build as a clean story (hide plumbing cards, live plan progress, narration prompt, write_file line counts)

* feat(tui): render agent narration bright (ink) so the build reads as a story

* feat(agent): demand an elaborate, structured closing summary (not a single line)

* feat(tui): mark agent narration with a leading bullet (selection-aligned)

* feat: post-turn recap line (※ recap:) with /config recaps on|off toggle

* feat(tui): show 'still generating…' during quiet stretches + count tool-call streaming

* feat(tui): ACTIVITY panel under PLAN + push the model to step the plan per file

* fix(tui): show the quiet 'still generating' hint only when the sidebar is hidden

* feat(tui): code preview in write/edit/apply cards (card-only, no model token cost)

* fix(tui): address CodeRabbit review — stale plan timestamps, stale explanation, Ctrl+B persistence, UTF-8 truncation

* fix: address CodeRabbit re-review round 2 (7 actionable + 2 nitpicks)

* chore: drop accidentally-committed WIP internal/cli/dryrun_test.go

It is an unrelated, pre-existing dry-run test that does not compile (references a
non-existent agent.Options.DryRun field, an unused import, and out-of-scope
sandbox symbols). It was swept into the prior commit by 'git add -A' and is not
part of this PR's scope.
2026-06-25 20:23:29 +05:30

141 lines
5.2 KiB
Go

package tui
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
)
// TestCurrentStepContent: the header/summary name the step actually being
// worked (in_progress, else first incomplete, else first), not always step 1.
func TestCurrentStepContent(t *testing.T) {
steps := []planStep{
{content: "one", status: "completed"},
{content: "two", status: "in_progress"},
{content: "three", status: "pending"},
}
if got := currentStepContent(steps); got != "two" {
t.Errorf("in_progress: want %q, got %q", "two", got)
}
steps[1].status = "completed" // no in_progress -> first not-yet-terminal
if got := currentStepContent(steps); got != "three" {
t.Errorf("first incomplete: want %q, got %q", "three", got)
}
steps[2].status = "completed" // all done -> first
if got := currentStepContent(steps); got != "one" {
t.Errorf("all complete: want %q, got %q", "one", got)
}
if got := currentStepContent(nil); got != "" {
t.Errorf("empty: want %q, got %q", "", got)
}
}
// TestPlanReconcileClearsStaleCompletion: a positional carry-over (same step
// count, no content match) must NOT bleed a prior completed step's completedAt
// into a new in_progress/pending step — that would show an old finished duration.
func TestPlanReconcileClearsStaleCompletion(t *testing.T) {
var s planPanelState
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s.updateFromItems([]tools.PlanItem{{Content: "old step", Status: "completed"}}, t0)
if s.steps[0].completedAt.IsZero() {
t.Fatal("a completed step should carry a completedAt")
}
// Reword to a NEW in_progress step (same count -> positional carry-over copies
// the old completedAt); it must be cleared so the live step shows a running clock.
s.updateFromItems([]tools.PlanItem{{Content: "new step", Status: "in_progress"}}, t0.Add(time.Minute))
if !s.steps[0].completedAt.IsZero() {
t.Errorf("in_progress step must not inherit a stale completedAt, got %v", s.steps[0].completedAt)
}
}
// TestPlanCompleteRemaining: force-completing a stuck plan flips every
// non-terminal step to completed, backfills timestamps, preserves a failed step,
// and is a clean no-op on empty / already-complete plans.
func TestPlanCompleteRemaining(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
t.Run("force-completes a mid-progress plan", func(t *testing.T) {
started := now.Add(-time.Minute)
s := planPanelState{steps: []planStep{
{content: "a", status: "completed", startedAt: started, completedAt: now.Add(-30 * time.Second)},
{content: "b", status: "in_progress", startedAt: started},
{content: "c", status: "pending"},
}}
s.completeRemaining(now)
if !s.isComplete() {
t.Fatalf("plan should be complete, steps=%+v", s.steps)
}
if s.completedAt != now {
t.Errorf("panel completedAt = %v, want %v", s.completedAt, now)
}
// in_progress keeps its real startedAt; gets completedAt=now (real duration).
if s.steps[1].startedAt != started || s.steps[1].completedAt != now {
t.Errorf("in_progress timestamps wrong: %+v", s.steps[1])
}
// pending gets startedAt=completedAt=now.
if s.steps[2].status != "completed" || s.steps[2].startedAt != now || s.steps[2].completedAt != now {
t.Errorf("pending step not completed cleanly: %+v", s.steps[2])
}
})
t.Run("preserves a failed step", func(t *testing.T) {
s := planPanelState{steps: []planStep{
{content: "a", status: "failed", startedAt: now, completedAt: now},
{content: "b", status: "pending"},
}}
s.completeRemaining(now)
if s.steps[0].status != "failed" {
t.Errorf("failed step should stay failed, got %q", s.steps[0].status)
}
if !s.isComplete() {
t.Error("failed + completed should count as complete")
}
})
t.Run("no-op on empty plan", func(t *testing.T) {
var s planPanelState
s.completeRemaining(now)
if !s.completedAt.IsZero() || len(s.steps) != 0 {
t.Errorf("empty plan should stay empty: completedAt=%v steps=%d", s.completedAt, len(s.steps))
}
})
t.Run("preserves completedAt on an already-complete plan", func(t *testing.T) {
orig := now.Add(-time.Hour)
s := planPanelState{
steps: []planStep{{content: "a", status: "completed", startedAt: orig, completedAt: orig}},
completedAt: orig,
}
s.completeRemaining(now)
if s.completedAt != orig {
t.Errorf("completedAt should be preserved, got %v want %v", s.completedAt, orig)
}
})
}
// TestPlanRewordKeepsTimer: when the model rewords a step in place (same step
// count), its elapsed clock must NOT reset — the positional carry-over preserves
// startedAt that the content-only match used to drop.
func TestPlanRewordKeepsTimer(t *testing.T) {
var s planPanelState
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s.updateFromItems([]tools.PlanItem{
{Content: "build the thing", Status: "in_progress"},
{Content: "test it", Status: "pending"},
}, t0)
started := s.steps[0].startedAt
if started.IsZero() {
t.Fatal("expected startedAt set on first in_progress step")
}
t1 := t0.Add(30 * time.Second)
s.updateFromItems([]tools.PlanItem{
{Content: "build the thing (with caching)", Status: "in_progress"}, // reworded in place
{Content: "test it", Status: "pending"},
}, t1)
if s.steps[0].startedAt != started {
t.Errorf("reworded step reset its timer: want %v, got %v", started, s.steps[0].startedAt)
}
}