mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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.
141 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|