From c38b643350c8b632754f989584a456e7983b47f5 Mon Sep 17 00:00:00 2001 From: Divij <148875733+divijg19@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:18:59 +0530 Subject: [PATCH] v0.10.3: Compare convergence, reference persistence, scrollable body, richer preview Compare Workspace convergence: - Add model.Result.Sequence for stable O(1) request identity (#NNN). - Rename PinnedBaseline -> Reference across model, workspace, render and docs; RenounceReference() is the sole way to drop the cross-run reference. - x is context-sensitive: clears the session (reference preserved) or, with only a reference, renounces it; startRun carries the reference forward. - Comparison identity block and Raw view show request # and timestamp for both participants. - Body/Raw compare views are scrollable with a position hint. - Enrich the right-side preview drawer (identity, verdict, keybindings). - Remove dead no-op Exit(); esc returns to Observe without mutation. Polish / cleanup pass: - Remove dead code: IsIdle, HasCandidate, stray EXECUTE orientation label, redundant payloadGeometry field, and the redundant Model.status field (tests now assert on observable state). cancelRun clears m.cancel. - DRY: renderIdentityLine shared by comparison block and preview; header sections collapsed into one loop; shared promotedHeaderKeys set; unified timestamp formatting. - Correctness: withScrollHint and visibleWindow clamp against short heights; verifySectionOrdering now actually runs across the three views. - Tests: enterComparing/setComparing helpers de-duplicate setup; tighter timestamp assertion. - Docs: drop non-existent Exit() op and ComparisonSession -> CompareWorkspace; tui/README links the COMPARE_* docs; Makefile arch gates pruned and doc-freshness checks real paths. --- Makefile | 29 +-- internal/model/result.go | 1 + internal/tui/COMPARE_CONSTITUTION.md | 38 ++-- internal/tui/COMPARE_WORKFLOW.md | 63 ++++--- internal/tui/README.md | 2 + internal/tui/architecture_test.go | 260 ++++++++++++++++----------- internal/tui/audit.go | 2 - internal/tui/geometry_test.go | 6 +- internal/tui/interaction_test.go | 3 - internal/tui/model.go | 54 +++--- internal/tui/model_test.go | 38 ++-- internal/tui/render_compare.go | 200 ++++++++++++++++----- internal/tui/render_context.go | 25 ++- internal/tui/render_inspect_why.go | 6 +- internal/tui/render_observe.go | 9 +- internal/tui/update_compare.go | 5 +- internal/tui/update_inspect.go | 2 + internal/tui/view.go | 7 +- internal/tui/view_test.go | 3 - internal/tui/workspace.go | 50 +++--- 20 files changed, 483 insertions(+), 320 deletions(-) diff --git a/Makefile b/Makefile index aac9053..c62b290 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: test vet lint race tidy fmt build cert audit-render audit-geometry \ - audit-duplicates audit-todo audit-deadcode audit-docs arch-cert +.PHONY: test vet lint race tidy fmt build cert audit-render \ + audit-todo audit-deadcode doc-freshness arch-cert # ============================================================================ # Standard Go gates @@ -53,23 +53,6 @@ audit-render: echo " PASS: no render mutations"; \ fi -audit-geometry: - @echo "[arch] Geometry ownership audit..." - @if grep -qn '\.SetWidth\|\.SetHeight\|\.Width =\|\.Height =' internal/tui/render_*.go; then \ - echo " FAIL: geometry mutation in render"; \ - exit 1; \ - else \ - echo " PASS: no geometry mutations in render"; \ - fi - -audit-duplicates: - @echo "[arch] Duplicate helper audit..." - @# Check for indent constants defined in only one place (as expected) - @grep -rn '^const\|^var' internal/tui/*.go | grep -v '_test.go' | grep -i 'indent\|gap\|pad' \ - | awk '{count[$$0]++} END {for (k in count) if (count[k] > 1) print " DUPLICATE: " k}' - @# Check for identical function implementations across files - @echo " PASS" - audit-todo: @echo "[arch] TODO audit..." @if grep -rn 'TODO\|FIXME\|HACK\|XXX' internal/tui/*.go | grep -v '_test.go' | grep . > /dev/null 2>&1; then \ @@ -91,7 +74,9 @@ audit-deadcode: doc-freshness: @echo "[arch] Documentation freshness audit..." - @for doc in RENDERING.md STATE_OWNERSHIP.md; do \ + @for doc in README.md ARCHITECTURE.md RENDERING.md \ + internal/tui/README.md internal/tui/STATE_OWNERSHIP.md \ + internal/tui/COMPARE_CONSTITUTION.md internal/tui/COMPARE_WORKFLOW.md; do \ if [ -f "$$doc" ]; then \ echo " OK: $$doc"; \ else \ @@ -107,10 +92,10 @@ doc-freshness: compilation: fmt vet build tidy @echo "[compilation] All compilation gates PASS" -quick: test vet lint +quick: test lint @echo "[quick] All quick gates PASS" -arch-cert: audit-render audit-geometry audit-duplicates audit-todo audit-deadcode doc-freshness +arch-cert: audit-render audit-todo audit-deadcode doc-freshness @echo "[arch] All architectural gates PASS" cert: compilation quick race arch-cert diff --git a/internal/model/result.go b/internal/model/result.go index e0fc608..dd592a7 100644 --- a/internal/model/result.go +++ b/internal/model/result.go @@ -3,6 +3,7 @@ package model import "time" type Result struct { + Sequence int `json:"sequence,omitempty"` Status int `json:"status"` Latency time.Duration `json:"latencyNs"` Timestamp time.Time `json:"timestamp"` diff --git a/internal/tui/COMPARE_CONSTITUTION.md b/internal/tui/COMPARE_CONSTITUTION.md index 11908b3..a7556b1 100644 --- a/internal/tui/COMPARE_CONSTITUTION.md +++ b/internal/tui/COMPARE_CONSTITUTION.md @@ -1,5 +1,7 @@ # Comparison Constitution +↩ [internal/tui/README.md](README.md) · [COMPARE_WORKFLOW.md](COMPARE_WORKFLOW.md) + ## Purpose This document defines what Compare is, what it is not, what operator questions it answers, and the architectural boundaries that govern its implementation. It serves as the specification for the v0.10.x "Explain Comparisons" release. @@ -159,7 +161,7 @@ current `m.results` slice to know what it is comparing. type CompareWorkspace struct { Baseline *model.Result // current baseline (resolved value) Candidate *model.Result // current candidate (resolved value) - PinnedBaseline *model.Result // persists across runs + Reference *model.Result // persists across runs State CompareState View CompareView Analysis *ComparisonAnalysis @@ -181,7 +183,7 @@ type CompareContext struct { `CompareWorkspace.Context()` resolves the workspace into a renderer-facing projection. The renderer never knows whether `Baseline` originated from the -current session or a pinned baseline — it simply renders what it is given. +current session or a reference — it simply renders what it is given. ### Workflow operations @@ -190,17 +192,16 @@ Every transition is expressed through exactly one method on `CompareWorkspace`: | Operation | Effect | |-----------|--------| | `MarkBaseline(r)` | Establish r as baseline; enter `BaselineMarked`; clear candidate + analysis. | -| `Unmark()` | Clear baseline + candidate; return to `Idle`. Pinned baseline preserved. | +| `Unmark()` | Clear baseline + candidate; return to `Idle`. Reference preserved. | | `SelectCandidate(r)` | Establish r as candidate; enter `Comparing`; recompute analysis once. | | `ReplaceCandidate(r)` | Replace candidate; recompute analysis once. | | `ReplaceBaseline(r)` | Replace baseline; recompute analysis once. | | `Swap()` | Exchange baseline and candidate; recompute analysis (perspective changes, content deltas do not). | -| `Clear()` | Reset baseline, candidate, state, analysis to zero; `Idle`. Pinned baseline preserved. | -| `Exit()` | No mutation — preserve the entire comparison for resume. | +| `Clear()` | Reset baseline, candidate, state, analysis to zero; `Idle`. Reference preserved. | | `NextView()` / `PrevView()` | Advance / retreat the active view, wrapping around. | -Predicates replace ad-hoc enum checks: `IsIdle`, `HasBaseline`, `HasCandidate`, -`IsComparing`, `HasPinnedBaseline`, `IsBaselineResult`, `IsCandidateResult`. +Predicates replace ad-hoc enum checks: `HasBaseline`, `IsComparing`, +`HasReference`, `IsBaselineResult`, `IsCandidateResult`. ### Analysis is computed exactly once per transition @@ -213,7 +214,7 @@ renderers consume the immutable `*ComparisonAnalysis` and never recompute. ``` Idle │ - │ c on result (no PinnedBaseline) + │ c on result (no Reference) ▼ BaselineMarked │ @@ -236,10 +237,10 @@ BaselineMarked ``` Notes: -- `x` (Clear) resets the workspace (Baseline, Candidate, State, Analysis). PinnedBaseline is preserved. +- `x` (Clear) resets the workspace (Baseline, Candidate, State, Analysis). Reference is preserved. - `c` on the baseline result (in any state) clears the workspace — the same way `x` does. - `c` on a different result (in Comparing) replaces the candidate. -- `c` with `PinnedBaseline` set (in Idle) immediately enters Compare, using the pinned result as the implicit baseline. +- `c` with `Reference` set (in Idle) immediately enters Compare, using reference result as the implicit baseline. - `Esc` (Exit) performs no mutation; the operator resumes exactly where they left off. --- @@ -316,7 +317,7 @@ This structure is the single source of truth for the renderer. The renderer rece | Regression coloring | Red for regressions, green for improvements, yellow for anomalies | | Workflow | Mark from Observe, swap, replace candidate, separate clear from exit, cross-run pinning | | Discoverability | Ribbon actions, active/marked/pinned markers in Observe list | -| State model | ComparisonSession with lifecycle, Pinned flag for cross-run survival | +| State model | CompareWorkspace with lifecycle, Reference for cross-run survival | ### Explicitly deferred (v1.0 or later) @@ -396,7 +397,7 @@ maintainable. Nothing outside the Compare subsystem changes. Compare is no longer a modal or a temporary screen. It is a persistent workspace that evolves as the operator works: -* **Idle** — no active comparison. A pinned baseline may still be displayed. +* **Idle** — no active comparison. A reference may still be displayed. * **BaselineMarked** — a baseline is chosen; Observe shows a collapsed preview. * **Comparing** — both baseline and candidate are set; the workspace is live. @@ -425,7 +426,7 @@ Every view renderer consumes the identical immutable `CompareContext`. ### State is always visible -The operator never mentally tracks what is marked, pinned, baseline, candidate, +The operator never mentally tracks what is marked, referenced, baseline, candidate, or resumable. The interface communicates it continuously via timeline markers (▶ Candidate, ◆ Baseline, ● Pinned) and the Compare identity header (`renderComparisonIdentityBlock`). @@ -444,11 +445,14 @@ a `Model` value — a pointer receiver would cause an interface conversion panic | CompareWorkspace | Single source of truth for all comparison state; resolved `*model.Result` pointers | | CompareContext | Renderer-facing projection; immutable during a frame | | View-oriented rendering | Overview / Evidence / Diff / Headers / Body / Raw, bracket-navigable | -| Collapsed preview | The preview **is** the Compare workspace with partial context — same renderer and identity block, plus verdict and an orientation/resume hint | +| Collapsed preview | The preview **is** the Compare workspace with partial context — same renderer and identity block, plus verdict and a context-specific keybinding line (open / clear / swap / renounce) | | Resumable workspace | `Esc` preserves the workspace; `c` on the candidate (▶) re-enters Compare without disturbing the comparison | -| Orientation at every width | Below 140 columns the status line reports the active comparison, so the operator never loses context | +| Request identity | Every participant's identity header carries its request number (`#N`) and timestamp; the Raw view prints a `Request:` line with the same | +| Persistence renounce | `x` outside Compare clears the active comparison (reference preserved); with only a reference it renounces the reference via `RenounceReference()` | +| Scrollable bodies | Body and Raw views scroll through their full content; scroll position persists across view switches | +| Orientation at every width | Below 140 columns the status line reports the active comparison (incl. `Reference · x renounces`), so the operator never loses context | | Workflow convergence | `c`, `s`, `x`, `Esc` each own exactly one meaning | -| Terminology convergence | Mark / Pinned Baseline / Candidate / Compare / Swap / Clear / Exit / Resume | +| Terminology convergence | Mark / Reference / Candidate / Compare / Swap / Clear / Exit / Resume | --- @@ -460,7 +464,7 @@ a `Model` value — a pointer receiver would cause an interface conversion panic 3. **This release does not add statistical or multi-sample analysis.** Every Comparison compares exactly two single-shot results. -4. **This release does not implement a comparison history.** Sessions are in-memory only. Pinned baselines survive one startRun cycle but are not persisted to disk. +4. **This release does not implement a comparison history.** Sessions are in-memory only. References survive one startRun cycle but are not persisted to disk. 5. **This release does not change the Observe result list beyond adding comparison markers** (▶, ◆, ●). diff --git a/internal/tui/COMPARE_WORKFLOW.md b/internal/tui/COMPARE_WORKFLOW.md index 4077c11..634fb66 100644 --- a/internal/tui/COMPARE_WORKFLOW.md +++ b/internal/tui/COMPARE_WORKFLOW.md @@ -1,5 +1,7 @@ # Compare Workflow +↩ [internal/tui/README.md](README.md) · [COMPARE_CONSTITUTION.md](COMPARE_CONSTITUTION.md) + ## Purpose This document defines the Compare workflow lifecycle, behavioural invariants, and canonical vocabulary. It is the operator-facing companion to `COMPARE_CONSTITUTION.md` (which covers architecture and rendering). @@ -39,7 +41,7 @@ BaselineMarked | State | Meaning | |-------|---------| -| Idle | No comparison workflow active. PinnedBaseline may still exist. | +| Idle | No comparison workflow active. Reference may still exist. | | BaselineMarked | A baseline result is selected, but no candidate has been chosen. | | Comparing | Both baseline and candidate are set. Comparison analysis is computed and displayed. | @@ -50,18 +52,23 @@ candidate are resolved `*model.Result` pointers — there are no indices. | From | Trigger | To | Effect | |------|---------|----|--------| -| Idle | `c` on result (no PinnedBaseline) | BaselineMarked | `MarkBaseline(r)`; resolved baseline pointer set. | -| Idle | `c` on result (PinnedBaseline set) | Comparing | Baseline = PinnedBaseline, then `SelectCandidate(r)`. | -| BaselineMarked | `c` on same result | Idle | `Unmark()`; PinnedBaseline preserved. | +| Idle | `c` on result (no Reference) | BaselineMarked | `MarkBaseline(r)`; resolved baseline pointer set. | +| Idle | `c` on result (Reference set) | Comparing | Baseline = Reference, then `SelectCandidate(r)`. | +| BaselineMarked | `c` on same result | Idle | `Unmark()`; Reference preserved. | | BaselineMarked | `c` on different result | Comparing | `SelectCandidate(r)`; analysis recomputed once. | -| Comparing | `c` on baseline result | Idle | `Clear()`; PinnedBaseline preserved. | +| Comparing | `c` on baseline result | Idle | `Clear()`; Reference preserved. | | Comparing | `c` on candidate result | Comparing | Resume workspace (`Enter Compare`); comparison unchanged. | | Comparing | `c` on different result | Comparing | `ReplaceCandidate(r)`; analysis recomputed once. | | Comparing | `s` | Comparing | `Swap()`; baseline/candidate exchanged, analysis recomputed. | -| Comparing | `x` | Idle | `Clear()`; PinnedBaseline preserved, returns to Observe. | -| Any | `Esc` | Observe | `Exit()` — no mutation; session preserved for resume. | +| Comparing | `x` | Idle | `Clear()`; Reference preserved, returns to Observe. | +| Any | `Esc` | Observe | Esc handled inline (no workspace mutation); session preserved for resume. | | Any | `q` | Quit dialog | Application quit on confirm. | +Outside Compare mode (Observe / Inspect), `x` is context-sensitive: it clears +the active comparison (Baseline + Candidate + State + Analysis) while preserving +the reference; when **only** a reference remains, `x` renounces the +reference entirely (`RenounceReference()`), ending its persistence across runs. + --- ## Views @@ -70,20 +77,26 @@ Once Comparing, the workspace is presented through discrete views. Every view consumes the identical immutable `CompareContext`; switching views only changes presentation, never the data. +Each participant's identity header carries its **request number** (`#001`, +1-based arrival order) and **timestamp**, so the operator always knows *what* +and *when* without leaving the workspace. The Raw view additionally prints a +`Request:` line with the same number and time for both baseline and candidate. + | View | Content | |------|---------| | Overview | Verdict + Why (direction-only sentences). Default. | | Evidence | Categorical deltas: Status, Latency, Headers, Body, Errors. | | Diff | URL change and full body diff. | | Headers | Added / removed / changed response headers. | -| Body | Full baseline and candidate response bodies. | -| Raw | Full baseline and candidate result dumps. | +| Body | Full baseline and candidate response bodies — scrollable. | +| Raw | Full baseline and candidate result dumps — scrollable. | Navigation: * `[` / `]` — previous / next view (wraps around). * `Tab` / `Shift+Tab` — same as bracket navigation. -* `up` / `k` / `down` / `j` — scroll the active view. +* `up` / `k` / `down` / `j` — scroll the active view (Body and Raw scroll through + their full content; scroll position persists across view switches). * `Esc` — Exit (preserves the view for resume). The current view name is shown in the status line (`Comparing · `). @@ -98,14 +111,22 @@ context panel the moment a baseline is marked, and persists until cleared. It always shows: -* baseline identity (◆) and, when present, candidate identity (▶), +* baseline identity (◆) and, when present, candidate identity (▶), each with its + request number and timestamp; a pinned-only state shows `● Pinned Baseline`, * the verdict when a comparison is active, -* an orientation hint: `c on ▶ to open · x clears` (comparing) or - `c on a result to compare` (baseline only). +* a context-specific keybinding line so the next action is obvious without + leaving the drawer: `c on ▶ open · x clear · s swap · [ ] view` (comparing), + `c compare · x clear` (baseline marked), or `c compare · x renounce` + (reference only). + +The preview is the right-side drawer: it is deliberately richer than a preview +so the operator can act on the comparison (open, clear, swap, renounce) directly +from Observe/Inspect without entering the full workspace. Even when the context panel is hidden (terminal narrower than 140 columns), the status line reports the active comparison (`Comparing · c on ▶ to open` / -`Baseline marked · c to compare`), so the operator never loses orientation. +`Baseline marked · c to compare` / `Reference · x renounces`), so the +operator never loses orientation. Because the preview shares `renderComparisonIdentityBlock` with the full workspace, the two never drift. @@ -114,13 +135,13 @@ workspace, the two never drift. ## Behavioural Invariants -1. **Compare never destroys a baseline implicitly.** Clear (`x`) resets the session but leaves PinnedBaseline intact. Only a new `startRun` may update PinnedBaseline. +1. **Compare never destroys a baseline implicitly.** Clear (`x`) resets the session but leaves Reference intact. Only a new `startRun` may update Reference. 2. **Swap never changes comparison content.** Swap exchanges the resolved baseline and candidate pointers. The same two results are compared — only the perspective changes, so the directional verdict reflects the new orientation. 3. **Exit never clears comparison.** `Esc` from Compare mode returns to Observe while preserving Session state. The operator can resume viewing the comparison at any time. -4. **Clear only clears the comparison session.** `x` resets Baseline, Candidate, State, and Analysis to their zero values via `Clear()`. PinnedBaseline is never modified by Clear. +4. **Clear only clears the comparison session.** `x` resets Baseline, Candidate, State, and Analysis to their zero values via `Clear()`. Reference is never modified by Clear. 5. **Rendering never mutates workflow state.** All render functions accept the model as a value receiver and produce output without side effects. Workflow state changes occur only in `handle*Key` methods. @@ -141,13 +162,13 @@ workspace, the two never drift. | Exchange baseline and candidate | Swap | Ribbon, action | | End the active comparison | Clear Comparison | Ribbon, status | | Leave Compare without clearing | Exit Compare | Status, docs | -| Start comparison with PinnedBaseline | Compare (auto) | Status, implicit | +| Start comparison with Reference | Compare (auto) | Status, implicit | ### Prohibited synonyms | Avoid | Use Instead | |-------|-------------| -| Mark / Pin / Reference interchangeably | Mark Baseline / Pinned Baseline | +| Mark / Reference interchangeably | Mark Baseline / Reference | | Active result | Candidate | | Clear All / Reset | Clear Comparison | | Flip / Reverse | Swap | @@ -162,13 +183,13 @@ Workflow tests in `architecture_test.go` verify operator-facing behaviour: | Test | Story | |------|-------| | `TestV0102Workflow_MarkReplace` | Mark A, c on B → B is candidate | -| `TestV0102Workflow_ClearPreservesPin` | Compare, x → session cleared, pin intact | +| `TestV0102Workflow_ClearPreservesReference` | Compare, x → session cleared, reference intact | | `TestV0102Workflow_SwapTwiceReturns` | s then s → original orientation restored | | `TestV0102Workflow_ExitPreservesSession` | Esc → Observe → session preserved | | `TestV0102Workflow_ClearComparison` | Compare, x → Idle, Observe | -| `TestV0102Workflow_CrossRunPin` | Mark → startRun → new results → compare with pin | +| `TestV0102Workflow_CrossRunReference` | Mark → startRun → new results → compare with reference | | `TestV0102Workflow_MarkSameUnmarks` | c on already-marked → clears | -| `TestV0102Workflow_NoBaselineCleared` | PinnedBaseline only → UI shows ● Pinned Baseline | +| `TestV0102Workflow_NoBaselineCleared` | Reference only → UI shows ● Pinned Baseline | --- diff --git a/internal/tui/README.md b/internal/tui/README.md index b1e71eb..a34d9dc 100644 --- a/internal/tui/README.md +++ b/internal/tui/README.md @@ -133,6 +133,8 @@ New to the TUI codebase? Read in this order: | Document | What it answers | |---|---| | `STATE_OWNERSHIP.md` | Model field ownership, lifetime, mutation rules | +| `COMPARE_CONSTITUTION.md` | Comparison engine contract: state model, analysis, render projections | +| `COMPARE_WORKFLOW.md` | Comparison UX: keybindings, lifecycle, persistence, preview behaviour | | [RENDERING.md](../../RENDERING.md) | TUI rendering architecture, layout, render lifecycle, constitution | | [ARCHITECTURE.md](../../ARCHITECTURE.md) | System architecture, components, APIs, engine, concurrency | | [README.md](../../README.md) | Product overview, installation, quick start | diff --git a/internal/tui/architecture_test.go b/internal/tui/architecture_test.go index 2e67401..c64e60a 100644 --- a/internal/tui/architecture_test.go +++ b/internal/tui/architecture_test.go @@ -483,7 +483,7 @@ func TestV099Comparing_StateComparingImpliesAnalysis(t *testing.T) { m.workspace.compare.Baseline = &m.results[0] m.workspace.compare.Candidate = &m.results[1] m.workspace.compare.State = CompareComparing - m.workspace.compare.PinnedBaseline = &m.results[0] + m.workspace.compare.Reference = &m.results[0] m.workspace.compare.refreshAnalysis() m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) @@ -493,12 +493,12 @@ func TestV099Comparing_StateComparingImpliesAnalysis(t *testing.T) { } }) - // Path 3: inspect mode, Idle → Comparing with PinnedBaseline + // Path 3: inspect mode, Idle → Comparing with Reference t.Run("inspect idle with pin", func(t *testing.T) { m := setup(3) m.selected = 1 m.workspace.mode = modeInspect - m.workspace.compare.PinnedBaseline = &m.results[0] + m.workspace.compare.Reference = &m.results[0] m.workspace.compare.State = CompareIdle m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) @@ -527,7 +527,7 @@ func TestV099Comparing_StateComparingImpliesAnalysis(t *testing.T) { t.Run("swap in compare", func(t *testing.T) { m := setup(3) m.workspace.mode = modeCompare - m.workspace.compare.PinnedBaseline = &m.results[0] + m.workspace.compare.Reference = &m.results[0] m.workspace.compare.Baseline = &m.results[0] m.workspace.compare.Candidate = &m.results[1] m.workspace.compare.State = CompareComparing @@ -545,10 +545,10 @@ func TestV099Comparing_StateComparingImpliesAnalysis(t *testing.T) { } func TestV099Comparing_PinInvariant(t *testing.T) { - // PinnedBaseline == nil ⇒ Session.State != SessionComparing + // Reference == nil on a fresh Model m := NewModel() - if m.workspace.compare.PinnedBaseline != nil { - t.Fatal("fresh Model should have nil PinnedBaseline") + if m.workspace.compare.Reference != nil { + t.Fatal("fresh Model should have nil Reference") } if m.workspace.compare.State == CompareComparing { t.Fatal("fresh Model should not be in Comparing state") @@ -764,11 +764,7 @@ func TestV0100_CompareBehaviourScenarios(t *testing.T) { t.Run(sc.name, func(t *testing.T) { m := NewModel() m.results = []model.Result{sc.baseline, sc.candidate} - m.workspace.compare.Baseline = &sc.baseline - m.workspace.compare.Candidate = &sc.candidate - m.workspace.compare.State = CompareComparing - m.workspace.compare.PinnedBaseline = &sc.baseline - m.workspace.compare.refreshAnalysis() + setComparing(&m, &sc.baseline, &sc.candidate) m.workspace.compare.View = CompareViewOverview outOverview := m.renderCompare(Region{Width: 100, Height: 30}) @@ -779,6 +775,11 @@ func TestV0100_CompareBehaviourScenarios(t *testing.T) { m.workspace.compare.View = CompareViewDiff outDetails := m.renderCompare(Region{Width: 100, Height: 30}) + // The semantic order (WHY → EVIDENCE → DETAILS) must hold across the + // three views taken together; renderCompare renders one view per call + // so the ordering can only be asserted on the concatenation. + verifySectionOrdering(t, outOverview+"\n"+outEvidence+"\n"+outDetails) + if !strings.Contains(outOverview, sc.wantVerdict) { t.Errorf("expected verdict %q, got output:\n%s", sc.wantVerdict, outOverview) } @@ -809,18 +810,13 @@ func TestV0100_CompareResponsiveInvariants(t *testing.T) { baseline := model.Result{Status: 200, Latency: 50 * time.Millisecond, RequestURL: "https://example.com/a"} candidate := model.Result{Status: 500, Latency: 100 * time.Millisecond, RequestURL: "https://example.com/b"} m.results = []model.Result{baseline, candidate} - m.workspace.compare.Baseline = &baseline - m.workspace.compare.Candidate = &candidate - m.workspace.compare.State = CompareComparing - m.workspace.compare.PinnedBaseline = &baseline - m.workspace.compare.refreshAnalysis() + setComparing(&m, &baseline, &candidate) m.workspace.compare.View = CompareViewEvidence widths := []int{80, 100, 119, 120, 150} for _, w := range widths { t.Run(fmt.Sprintf("width_%d", w), func(t *testing.T) { out := m.renderCompare(Region{Width: w, Height: 30}) - verifySectionOrdering(t, out) if strings.Contains(out, "│") { t.Errorf("Wide layout must not contain '│' column separator anymore:\n%s", out) @@ -841,11 +837,7 @@ func TestV0100_CompareRendererConsumesAnalysisOnly(t *testing.T) { baseline := model.Result{Status: 200, Latency: 50 * time.Millisecond, RequestURL: "https://example.com/a"} candidate := model.Result{Status: 500, Latency: 100 * time.Millisecond, RequestURL: "https://example.com/b"} m.results = []model.Result{baseline, candidate} - m.workspace.compare.Baseline = &baseline - m.workspace.compare.Candidate = &candidate - m.workspace.compare.State = CompareComparing - m.workspace.compare.PinnedBaseline = &baseline - m.workspace.compare.refreshAnalysis() + setComparing(&m, &baseline, &candidate) baseOutput := m.renderCompare(Region{Width: 100, Height: 30}) @@ -867,11 +859,7 @@ func TestV0100_CompareRenderDoesNotMutate(t *testing.T) { baseline := model.Result{Status: 200, Latency: 50 * time.Millisecond, RequestURL: "https://example.com/a"} candidate := model.Result{Status: 500, Latency: 100 * time.Millisecond, RequestURL: "https://example.com/b"} m.results = []model.Result{baseline, candidate} - m.workspace.compare.Baseline = &baseline - m.workspace.compare.Candidate = &candidate - m.workspace.compare.State = CompareComparing - m.workspace.compare.PinnedBaseline = &baseline - m.workspace.compare.refreshAnalysis() + setComparing(&m, &baseline, &candidate) activeDomain := m.activeDomain running := m.running @@ -896,6 +884,32 @@ func TestV0100_CompareRenderDoesNotMutate(t *testing.T) { // v0.10.2 — Compare Workflow Convergence Tests // --------------------------------------------------------------------------- +// enterComparing marks results[baselineIdx] as baseline and compares it against +// results[candidateIdx], returning the Model in the Comparing state. It factors +// out the repeated two-press sequence shared by the workflow tests. +func enterComparing(t *testing.T, baselineIdx, candidateIdx int) Model { + t.Helper() + m := NewModel() + m.results = testResults(3) + m.selected = baselineIdx + updated, _ := m.Update(keyMsgRune('c')) + m = updated.(Model) + m.selected = candidateIdx + updated, _ = m.Update(keyMsgRune('c')) + return updated.(Model) +} + +// setComparing wires a Model into the Comparing state with the given baseline +// and candidate, recomputing the analysis exactly once — mirroring how the +// workspace reaches that state through MarkBaseline + SelectCandidate. +func setComparing(m *Model, baseline, candidate *model.Result) { + m.workspace.compare.Baseline = baseline + m.workspace.compare.Candidate = candidate + m.workspace.compare.State = CompareComparing + m.workspace.compare.Reference = baseline + m.workspace.compare.refreshAnalysis() +} + func TestV0102Workflow_MarkReplace(t *testing.T) { // Mark A as baseline → c on B → Compare (B replaces A as baseline) m := NewModel() @@ -924,32 +938,23 @@ func TestV0102Workflow_MarkReplace(t *testing.T) { } } -func TestV0102Workflow_ClearPreservesPin(t *testing.T) { - // Start a comparison, pin it, then clear — PinnedBaseline survives - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - // Mark baseline, press c on index 1 to compare - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) +func TestV0102Workflow_ClearPreservesReference(t *testing.T) { + // Start a comparison, reference it, then clear — Reference survives + m := enterComparing(t, 0, 1) - // Pin the baseline (simulate startRun pinning) - m.workspace.compare.PinnedBaseline = &m.results[0] + // Reference the baseline (simulate startRun referencing) + m.workspace.compare.Reference = &m.results[0] // Enter Compare mode and press x m.workspace.mode = modeCompare - updated, _ = m.Update(keyMsgRune('x')) + updated, _ := m.Update(keyMsgRune('x')) m = updated.(Model) if m.workspace.compare.State != CompareIdle { t.Fatal("x should reset session to Idle") } - if m.workspace.compare.PinnedBaseline == nil { - t.Fatal("x must preserve PinnedBaseline") + if m.workspace.compare.Reference == nil { + t.Fatal("x must preserve Reference") } if m.workspace.mode != modeObserve { t.Fatal("x should return to Observe") @@ -958,23 +963,14 @@ func TestV0102Workflow_ClearPreservesPin(t *testing.T) { func TestV0102Workflow_SwapTwiceReturns(t *testing.T) { // Compare A vs B → s → B is baseline, A is candidate → s → back to original - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - // Mark baseline, compare with index 1 - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) + m := enterComparing(t, 0, 1) beforeBaseline := *m.workspace.compare.Baseline beforeCandidate := *m.workspace.compare.Candidate // Swap m.workspace.mode = modeCompare - updated, _ = m.Update(keyMsgRune('s')) + updated, _ := m.Update(keyMsgRune('s')) m = updated.(Model) if !resultsEqual(*m.workspace.compare.Baseline, beforeCandidate) { @@ -998,22 +994,14 @@ func TestV0102Workflow_SwapTwiceReturns(t *testing.T) { func TestV0102Workflow_ExitPreservesSession(t *testing.T) { // Compare → Esc → Observe → session preserved - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) + m := enterComparing(t, 0, 1) beforeBaseline := *m.workspace.compare.Baseline beforeCandidate := *m.workspace.compare.Candidate // Esc exits compare, preserves session m.workspace.mode = modeCompare - updated, _ = m.Update(keyMsgKey(tea.KeyEsc)) + updated, _ := m.Update(keyMsgKey(tea.KeyEsc)) m = updated.(Model) if m.workspace.mode != modeObserve { @@ -1033,15 +1021,7 @@ func TestV0102Workflow_ExitPreservesSession(t *testing.T) { func TestV0102Workflow_ResumeFromObserve(t *testing.T) { // Compare A vs B → Esc → Observe → c on candidate (▶) → re-enters Compare // without disturbing the comparison. - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) + m := enterComparing(t, 0, 1) if m.workspace.mode != modeCompare { t.Fatal("should be in Compare after second c") } @@ -1053,7 +1033,7 @@ func TestV0102Workflow_ResumeFromObserve(t *testing.T) { // c on the candidate (marked ▶) must resume the workspace. m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) + updated, _ := m.Update(keyMsgRune('c')) m = updated.(Model) if m.workspace.mode != modeCompare { @@ -1069,19 +1049,11 @@ func TestV0102Workflow_ResumeFromObserve(t *testing.T) { func TestV0102Workflow_ClearComparison(t *testing.T) { // Compare A vs B → x → session cleared, back to Observe - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - m.selected = 1 - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) + m := enterComparing(t, 0, 1) // x clears m.workspace.mode = modeCompare - updated, _ = m.Update(keyMsgRune('x')) + updated, _ := m.Update(keyMsgRune('x')) m = updated.(Model) if m.workspace.compare.State != CompareIdle { @@ -1092,9 +1064,9 @@ func TestV0102Workflow_ClearComparison(t *testing.T) { } } -func TestV0102Workflow_CrossRunPin(t *testing.T) { - // Mark baseline → startRun → PinnedBaseline set → session reset - // → c on new result → compare against pinned baseline +func TestV0102Workflow_CrossRunReference(t *testing.T) { + // Mark baseline → startRun → Reference set → session reset + // → c on new result → compare against reference baseline m := NewModel() m.results = testResults(3) m.selected = 0 @@ -1105,9 +1077,9 @@ func TestV0102Workflow_CrossRunPin(t *testing.T) { m = updated.(Model) // Simulate startRun - m.workspace.compare.PinnedBaseline = &m.results[0] + m.workspace.compare.Reference = &m.results[0] m.workspace.compare = NewCompareWorkspace() - m.workspace.compare.PinnedBaseline = &m.results[0] + m.workspace.compare.Reference = &m.results[0] m.results = nil // Add new results and compare @@ -1118,25 +1090,16 @@ func TestV0102Workflow_CrossRunPin(t *testing.T) { m = updated.(Model) if m.workspace.compare.State != CompareComparing { - t.Fatal("c with pinned baseline should enter Compare") + t.Fatal("c with reference baseline should enter Compare") } - if m.workspace.compare.PinnedBaseline == nil { - t.Fatal("PinnedBaseline must survive") + if m.workspace.compare.Reference == nil { + t.Fatal("Reference must survive") } } func TestV0102Workflow_MarkSameUnmarks(t *testing.T) { // Baseline marked → c on same result → clears session - m := NewModel() - m.results = testResults(3) - m.selected = 0 - - updated, _ := m.Update(keyMsgRune('c')) - m = updated.(Model) - - // c on same result - updated, _ = m.Update(keyMsgRune('c')) - m = updated.(Model) + m := enterComparing(t, 0, 0) if m.workspace.compare.State != CompareIdle { t.Fatal("c on same marked result should clear session") @@ -1147,14 +1110,95 @@ func TestV0102Workflow_MarkSameUnmarks(t *testing.T) { } func TestV0102Workflow_NoBaselineCleared(t *testing.T) { - // With PinnedBaseline set and no session, render shows Pinned state + // With Reference set and no session, render shows Reference state m := NewModel() - m.workspace.compare.PinnedBaseline = &model.Result{Status: 200, Latency: 10 * time.Millisecond} m.workspace.compare = NewCompareWorkspace() - m.workspace.compare.PinnedBaseline = &model.Result{Status: 200, Latency: 10 * time.Millisecond} + m.workspace.compare.Reference = &model.Result{Status: 200, Latency: 10 * time.Millisecond} out := m.renderCompare(Region{Width: 100, Height: 30}) if !strings.Contains(out, "● Pinned Baseline") { - t.Fatal("renderCompare with pinned baseline should show ● Pinned Baseline, got:\n", out) + t.Fatal("renderCompare with reference should show ● Pinned Baseline, got:\n", out) + } +} + +func TestCompare_RenderRequestDetails(t *testing.T) { + // Request number (#N) and timestamp must appear for both participants. + m := compareTestModel() + for i := range m.results { + m.results[i].Timestamp = time.Date(2024, 1, 2, 3, 4, 5+i, 0, time.UTC) + } + m.workspace.compare.Baseline = &m.results[0] + m.workspace.compare.Candidate = &m.results[1] + m.workspace.compare.State = CompareComparing + m.workspace.compare.refreshAnalysis() + region := Region{Width: 130, Height: 40} + + m.workspace.compare.View = CompareViewOverview + outOverview := m.renderCompare(region) + if !strings.Contains(outOverview, "#001") || !strings.Contains(outOverview, "03:04:05") { + t.Fatalf("identity should show request number and time, got:\n%s", outOverview) + } + + m.workspace.compare.View = CompareViewRaw + outRaw := m.renderCompare(region) + if !strings.Contains(outRaw, "Request:") { + t.Fatal("raw view should expose Request details") + } + if !strings.Contains(outRaw, "#001") || !strings.Contains(outRaw, "#002") { + t.Fatal("raw view should show request number for both participants") + } +} + +func TestV0102Workflow_RenounceReference(t *testing.T) { + // x in Observe with only a reference request renounces persistence. + m := NewModel() + m.results = testResults(3) + m.selected = 0 + updated, _ := m.Update(keyMsgRune('c')) + m = updated.(Model) + + // Simulate a run that sets the reference for the next run. + m.workspace.compare.Reference = &m.results[0] + m.workspace.compare = NewCompareWorkspace() + m.workspace.compare.Reference = &m.results[0] + + m.workspace.mode = modeObserve + updated, _ = m.Update(keyMsgRune('x')) + m = updated.(Model) + + if m.workspace.compare.Reference != nil { + t.Fatal("x should renounce the reference") + } + if m.workspace.compare.State != CompareIdle { + t.Fatal("workspace should be idle after renounce") + } +} + +func TestCompare_BodyScroll(t *testing.T) { + // The Body view must scroll through long baseline/candidate bodies. + m := compareTestModel() + big := "" + for i := 0; i < 50; i++ { + big += fmt.Sprintf("line %d\n", i) + } + m.results[0].ResponseBody = big + m.results[1].ResponseBody = "short candidate" + m.workspace.compare.Baseline = &m.results[0] + m.workspace.compare.Candidate = &m.results[1] + m.workspace.compare.State = CompareComparing + m.workspace.compare.refreshAnalysis() + region := Region{Width: 120, Height: 20} + + m.workspace.compare.View = CompareViewBody + m.inspectBodyOffset = 0 + top := m.renderCompare(region) + m.inspectBodyOffset = 10 + scrolled := m.renderCompare(region) + + if top == scrolled { + t.Fatal("body view should change when scrolled") + } + if strings.Contains(scrolled, "line 0") { + t.Fatal("scrolled body should have moved past the first line") } } diff --git a/internal/tui/audit.go b/internal/tui/audit.go index 62f65eb..a60ca84 100644 --- a/internal/tui/audit.go +++ b/internal/tui/audit.go @@ -58,7 +58,6 @@ func newReadyModel() Model { func newTimelineRunningModel() Model { m := NewModel() m.running = true - m.status = "RUNNING" m.startedAt = time.Now().Add(-5 * time.Second) m.elapsed = 5 * time.Second m.results = testResults(20) @@ -77,7 +76,6 @@ func newLogsRunningModel() Model { func newTimelineRunningEmptyModel() Model { m := NewModel() m.running = true - m.status = "RUNNING" m.startedAt = time.Now().Add(-2 * time.Second) m.elapsed = 2 * time.Second m.workspace.view = TimelineView diff --git a/internal/tui/geometry_test.go b/internal/tui/geometry_test.go index b1a4beb..27aa40a 100644 --- a/internal/tui/geometry_test.go +++ b/internal/tui/geometry_test.go @@ -159,7 +159,7 @@ func TestPayloadGeometry_WidthConsistency(t *testing.T) { m.shell.Resize(100, 30) m.syncPayloadGeometry(100) - geo := m.payloadGeometry + geo := calculatePayloadGeometry(100) bodyW := m.bodyInput.Width() if bodyW+4 != geo.BodyWidth { t.Fatalf("bodyInput.Width()+4 = %d, want %d (BodyWidth = %d)", @@ -179,10 +179,10 @@ func TestPayloadGeometry_ResizeIncreasesWidth(t *testing.T) { m := NewModel() m.shell.Resize(80, 24) m.syncPayloadGeometry(80) - narrow := m.payloadGeometry.BodyWidth + narrow := calculatePayloadGeometry(80).BodyWidth m.syncPayloadGeometry(160) - wide := m.payloadGeometry.BodyWidth + wide := calculatePayloadGeometry(160).BodyWidth if wide <= narrow { t.Fatalf("BodyWidth at 160 (%d) must be > BodyWidth at 80 (%d)", wide, narrow) diff --git a/internal/tui/interaction_test.go b/internal/tui/interaction_test.go index 3d7a227..a6e49db 100644 --- a/internal/tui/interaction_test.go +++ b/internal/tui/interaction_test.go @@ -1136,9 +1136,6 @@ func TestV092Interaction_CtrlXFromDialog(t *testing.T) { if m2.running { t.Fatal("ctrl+X from Request dialog should cancel the run") } - if m2.status != "CANCELLED" { - t.Fatalf("ctrl+X from Request dialog should set status to CANCELLED, got %q", m2.status) - } } // Regression: editable fields type h/j/k/l; body up/down stay in body. diff --git a/internal/tui/model.go b/internal/tui/model.go index 318ca9e..e311615 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -58,8 +58,6 @@ type Model struct { shell Shell workspace Workspace - payloadGeometry payloadGeometry - activeDomain DomainType methodIndex int requestField int @@ -79,7 +77,6 @@ type Model struct { elapsed time.Duration cancel context.CancelFunc eventCh <-chan model.Event - status string errMsg string summary metrics.Summary @@ -126,14 +123,12 @@ func NewModel() Model { return Model{ shell: NewShell(), workspace: NewWorkspace(), - payloadGeometry: geo, activeDomain: DomainRequest, methodIndex: 0, requestField: reqFieldURL, urlInput: url, concurrencyInput: cc, bodyInput: body, - status: "SYSTEM READY", } } @@ -148,7 +143,6 @@ func (m *Model) syncPayloadGeometry(contentWidth int) { m.headers[i].Key.Width = geo.KeyWidth m.headers[i].Value.Width = geo.ValueWidth } - m.payloadGeometry = geo } func workspaceContentWidth(shellWidth, shellHeight int) int { @@ -199,6 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case resultMsg: following := m.isFollowingTail() if len(m.results) < 10000 { + msg.Result.Sequence = len(m.results) + 1 m.results = append(m.results, msg.Result) } m.summary = metrics.Compute(m.results, m.elapsed) @@ -213,13 +208,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.elapsed = time.Since(m.startedAt) m.running = false m.errMsg = "ERROR: " + strings.ToUpper(msg.Err) - m.status = "ERROR: " + strings.ToUpper(msg.Err) return m, nil case runFinishedMsg: m.elapsed = time.Since(m.startedAt) - if m.running { - m.status = "COMPLETE" - } m.running = false m.errMsg = "" if m.cancel != nil { @@ -234,7 +225,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case error: m.errMsg = strings.ToUpper(msg.Error()) - m.status = "ERROR: " + strings.ToUpper(msg.Error()) return m, nil } @@ -592,7 +582,6 @@ func (m *Model) focusPayloadBody() { m.bodyInput.Focus() } -// computeComparisonAnalysis runs the Comparison Engine using the session's // handleMark applies the "Mark" verb to the result at the current selection. // It is the only place the c key performs workflow transitions. Every branch // routes through a single CompareWorkspace operation; this method never @@ -606,8 +595,8 @@ func (m Model) handleMark() (tea.Model, tea.Cmd) { switch { case w.State == CompareIdle: - if w.HasPinnedBaseline() { - w.Baseline = w.PinnedBaseline + if w.HasReference() { + w.Baseline = w.Reference w.SelectCandidate(r) m.workspace.mode = modeCompare m.inspectBodyOffset = 0 @@ -628,7 +617,7 @@ func (m Model) handleMark() (tea.Model, tea.Cmd) { } case w.State == CompareComparing: if w.IsBaselineResult(r) { - // c on the baseline clears the workspace (pinned baseline survives). + // c on the baseline clears the workspace (reference survives). w.Clear() m.workspace.mode = modeObserve m.inspectBodyOffset = 0 @@ -645,6 +634,20 @@ func (m Model) handleMark() (tea.Model, tea.Cmd) { return m, nil } +// handleRenounceOrClearKey implements the context-sensitive x binding: +// it clears the active comparison (keeping the reference), or renounces +// the reference when only a reference request remains. +func (m Model) handleRenounceOrClearKey() (tea.Model, tea.Cmd) { + w := &m.workspace.compare + if w.IsComparing() || w.HasBaseline() { + w.Clear() + } else if w.HasReference() { + w.RenounceReference() + } + m.inspectBodyOffset = 0 + return m, nil +} + func (m Model) handleObserveKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": @@ -688,6 +691,8 @@ func (m Model) handleObserveKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "c": return m.handleMark() + case "x": + return m.handleRenounceOrClearKey() case "e": m.workspace.dialog = dialogRequest m.activeDomain = DomainRequest @@ -723,13 +728,11 @@ func (m Model) startRun() (Model, tea.Cmd) { if err != nil { errStr := strings.ToUpper(err.Error()) m.errMsg = errStr - m.status = errStr return m, nil } if len(validated.Body) > maxTUIBodyBytes { m.errMsg = "BODY TOO LARGE (MAX 1MB)" - m.status = "BODY TOO LARGE (MAX 1MB)" return m, nil } @@ -741,16 +744,16 @@ func (m Model) startRun() (Model, tea.Cmd) { m.workspace.mode = modeObserve m.workspace.dialog = dialogNone - // Pin the marked baseline so it survives the run; then reset the active - // comparison. The pinned baseline is the only piece of comparison state - // that crosses a startRun boundary. - var pinned *model.Result + // Carry forward the reference request so it survives the run; then reset the + // active comparison. The reference is the only piece of comparison state that + // crosses a startRun boundary. + var ref *model.Result if m.workspace.compare.HasBaseline() { - p := *m.workspace.compare.Baseline - pinned = &p + r := *m.workspace.compare.Baseline + ref = &r } m.workspace.compare = NewCompareWorkspace() - m.workspace.compare.PinnedBaseline = pinned + m.workspace.compare.Reference = ref m.running = true m.cancel = cancel m.eventCh = eventCh @@ -759,7 +762,6 @@ func (m Model) startRun() (Model, tea.Cmd) { m.results = nil m.selected = 0 m.errMsg = "" - m.status = "RUNNING" m.summary = metrics.Summary{} m.inspectBodyOffset = 0 m.inspectZone = zoneWhatHappened @@ -789,8 +791,8 @@ func (m Model) isFollowingTail() bool { func (m Model) cancelRun() Model { if m.running && m.cancel != nil { m.cancel() - m.status = "CANCELLED" m.running = false + m.cancel = nil m.blurAll() } return m diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 510fe37..4782dca 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -80,8 +80,8 @@ func TestRunStartAndCancelTransitions(t *testing.T) { } cancelled := started.cancelRun() - if cancelled.status != "CANCELLED" { - t.Fatalf("status = %q", cancelled.status) + if cancelled.running { + t.Fatal("cancelRun should stop the run") } } @@ -190,8 +190,8 @@ func TestRejectsOversizedBody(t *testing.T) { if started.running { t.Fatal("model should not be running with oversized body") } - if started.status != "BODY TOO LARGE (MAX 1MB)" { - t.Fatalf("status = %q", started.status) + if started.errMsg != "BODY TOO LARGE (MAX 1MB)" { + t.Fatalf("errMsg = %q", started.errMsg) } } @@ -304,7 +304,6 @@ func TestRunFinishedMsg(t *testing.T) { m := NewModel() m.running = true m.startedAt = time.Now().Add(-2 * time.Second) - m.status = "RUNNING" updated, _ := m.Update(runFinishedMsg{}) m = updated.(Model) @@ -312,9 +311,6 @@ func TestRunFinishedMsg(t *testing.T) { if m.running { t.Fatal("running should be false after runFinishedMsg") } - if m.status != "COMPLETE" { - t.Fatalf("status = %q (expected COMPLETE)", m.status) - } } func TestCtrlC_QuitIdle(t *testing.T) { @@ -406,14 +402,10 @@ func TestCtrlX_Cancel(t *testing.T) { m := NewModel() m.running = true m.cancel = func() {} - m.status = "RUNNING" updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) m = updated.(Model) - if m.status != "CANCELLED" { - t.Fatalf("status = %q (expected CANCELLED)", m.status) - } if m.running { t.Fatal("running should be false after cancelRun") } @@ -512,7 +504,6 @@ func TestHeaderMap(t *testing.T) { func TestCtrlX_NotRunning(t *testing.T) { m := NewModel() - m.status = "SYSTEM READY" updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) m = updated.(Model) @@ -520,8 +511,8 @@ func TestCtrlX_NotRunning(t *testing.T) { if cmd != nil { t.Fatal("ctrl+x when not running should return nil cmd") } - if m.status != "SYSTEM READY" { - t.Fatalf("status should remain 'SYSTEM READY', got %q", m.status) + if m.errMsg != "" { + t.Fatalf("errMsg should remain empty, got %q", m.errMsg) } } @@ -551,11 +542,10 @@ func TestStartRun_AlreadyRunning(t *testing.T) { func TestCancelRun_NotRunning(t *testing.T) { m := NewModel() - m.status = "IDLE" result := m.cancelRun() - if result.status != "IDLE" { - t.Fatalf("status should remain 'IDLE', got %q", result.status) + if result.running { + t.Fatal("cancelRun when not running must remain idle") } } @@ -580,22 +570,16 @@ func TestMultipleRunLifecycle(t *testing.T) { } cancelled := started.cancelRun() - if cancelled.status != "CANCELLED" { - t.Fatalf("status after cancel = %q, want 'CANCELLED'", cancelled.status) - } if cancelled.running { t.Fatal("running should be false after cancelRun") } - // cancelRun sets running=false and status=CANCELLED; runFinishedMsg - // should not override the status when arriving afterwards. + // cancelRun sets running=false; a runFinishedMsg arriving afterwards must + // not resurrect the run. finished, _ := cancelled.Update(runFinishedMsg{}) m2 := finished.(Model) if m2.running { - t.Fatal("should not be running after runFinishedMsg") - } - if m2.status != "CANCELLED" { - t.Fatalf("status after cancel+finish = %q, want 'CANCELLED'", m2.status) + t.Fatal("runFinishedMsg must not restart a cancelled run") } restarted, cmd := m2.startRun() diff --git a/internal/tui/render_compare.go b/internal/tui/render_compare.go index 6e96138..c0e66ab 100644 --- a/internal/tui/render_compare.go +++ b/internal/tui/render_compare.go @@ -26,7 +26,7 @@ var ( func (m Model) renderCompare(region Region) string { w := m.workspace.compare if !w.IsComparing() { - if w.HasPinnedBaseline() { + if w.HasReference() { return regionStyle(region).Render( styleCompareMark.Render("● Pinned Baseline") + "\n" + styleMuted.Render("Select a result and press c to compare against the pinned baseline.")) @@ -42,7 +42,7 @@ func (m Model) renderCompare(region Region) string { return regionStyle(region).Render(styleMuted.Render("Comparison requires at least 80 columns.")) } - identity := renderComparisonIdentityBlock(&ctx.Baseline, &ctx.Candidate) + identity := m.renderComparisonIdentityBlock(&ctx.Baseline, &ctx.Candidate) var body string switch w.View { @@ -55,9 +55,9 @@ func (m Model) renderCompare(region Region) string { case CompareViewHeaders: body = renderCompareHeaders(ctx) case CompareViewBody: - body = renderCompareBody(ctx) + body = m.renderCompareBody(ctx, bodyRegion(region, identity)) case CompareViewRaw: - body = renderCompareRaw(ctx) + body = m.renderCompareRaw(ctx, bodyRegion(region, identity)) } var parts []string @@ -65,23 +65,54 @@ func (m Model) renderCompare(region Region) string { return regionStyle(region).Render(strings.Join(parts, "\n\n")) } +// requestNumber returns the 1-based request number for r within the current run, +// or the stored Sequence if r is from a prior run (e.g., a reference). The number is +// a property of the request itself, not derived from UI position. When r has no +// resolvable number it returns a neutral placeholder so callers keep the #NNN form. +func (m Model) requestNumber(r model.Result) string { + if r.Sequence != 0 { + return fmt.Sprintf("#%03d", r.Sequence) + } + for i, res := range m.results { + if resultsEqual(res, r) { + return fmt.Sprintf("#%03d", i+1) + } + } + return "#??" +} + +func requestTime(r model.Result) string { + if r.Timestamp.IsZero() { + return "(no time)" + } + return r.Timestamp.Format("15:04:05") +} + // renderComparisonIdentityBlock renders the orientation header. It shows the -// baseline and, when present, the candidate. The same block is used by the -// full Compare workspace and the collapsed preview, so the two never drift. -func renderComparisonIdentityBlock(baseline, candidate *model.Result) string { - var b strings.Builder +// baseline and, when present, the candidate, each annotated with its request +// number and timestamp. The same block is used by the full Compare workspace +// and the collapsed preview, so the two never drift. +func (m Model) renderComparisonIdentityBlock(baseline, candidate *model.Result) string { + var parts []string if baseline != nil { - b.WriteString(styleCompareMark.Render("◆ Baseline")) - b.WriteString("\n") - b.WriteString(styleMuted.Render(methodPath(*baseline))) - b.WriteString("\n\n") + parts = append(parts, m.renderIdentityLine("◆", "Baseline", *baseline)) } if candidate != nil { - b.WriteString(styleCompareMark.Render("▶ Candidate")) - b.WriteString("\n") - b.WriteString(styleMuted.Render(methodPath(*candidate))) + parts = append(parts, m.renderIdentityLine("▶", "Candidate", *candidate)) } - return strings.TrimRight(b.String(), "\n") + return strings.Join(parts, "\n\n") +} + +// renderIdentityLine renders one participant's marker, label, request number, +// timestamp and method+path on two lines. Shared by the comparison identity +// block and the preview drawer so the two presentations never diverge. +func (m Model) renderIdentityLine(marker, label string, r model.Result) string { + var b strings.Builder + b.WriteString(styleCompareMark.Render(fmt.Sprintf("%s %s %s", marker, label, m.requestNumber(r)))) + b.WriteString(styleMuted.Render(" · " + requestTime(r))) + b.WriteString("\n") + b.WriteString(styleMuted.Render(methodPath(r))) + return b.String() } func methodPath(result model.Result) string { @@ -112,26 +143,44 @@ func renderCompareDiff(ctx CompareContext) string { func renderCompareHeaders(ctx CompareContext) string { hdrs := ctx.Analysis.Headers - var b strings.Builder - if len(hdrs.Added) > 0 { - b.WriteString(sectionLine("ADDED HEADERS", false)) - for _, e := range hdrs.Added { - b.WriteString(fmt.Sprintf(" %s: %s\n", styleFieldName.Render(e.Name), e.Value)) - } - b.WriteString("\n") - } - if len(hdrs.Removed) > 0 { - b.WriteString(sectionLine("REMOVED HEADERS", false)) - for _, e := range hdrs.Removed { - b.WriteString(fmt.Sprintf(" %s: %s\n", styleFieldName.Render(e.Name), e.Value)) - } - b.WriteString("\n") + + // Each section shares the same shape (title + per-entry lines); only the + // entry formatting differs between added/removed and changed. + sections := []struct { + title string + render func() string + }{ + {"ADDED HEADERS", func() string { + var sb strings.Builder + for _, e := range hdrs.Added { + sb.WriteString(fmt.Sprintf(" %s: %s\n", styleFieldName.Render(e.Name), e.Value)) + } + return sb.String() + }}, + {"REMOVED HEADERS", func() string { + var sb strings.Builder + for _, e := range hdrs.Removed { + sb.WriteString(fmt.Sprintf(" %s: %s\n", styleFieldName.Render(e.Name), e.Value)) + } + return sb.String() + }}, + {"CHANGED HEADERS", func() string { + var sb strings.Builder + for _, e := range hdrs.Changed { + sb.WriteString(fmt.Sprintf(" %s: %s → %s\n", styleFieldName.Render(e.Name), e.OldValue, e.NewValue)) + } + return sb.String() + }}, } - if len(hdrs.Changed) > 0 { - b.WriteString(sectionLine("CHANGED HEADERS", false)) - for _, e := range hdrs.Changed { - b.WriteString(fmt.Sprintf(" %s: %s → %s\n", styleFieldName.Render(e.Name), e.OldValue, e.NewValue)) + + var b strings.Builder + for _, s := range sections { + body := s.render() + if body == "" { + continue } + b.WriteString(sectionLine(s.title, false)) + b.WriteString(body) b.WriteString("\n") } if b.Len() == 0 { @@ -140,7 +189,7 @@ func renderCompareHeaders(ctx CompareContext) string { return strings.TrimRight(b.String(), "\n") } -func renderCompareBody(ctx CompareContext) string { +func (m Model) renderCompareBody(ctx CompareContext, region Region) string { var b strings.Builder b.WriteString(sectionLine("BASELINE BODY", false)) b.WriteString("\n") @@ -149,23 +198,56 @@ func renderCompareBody(ctx CompareContext) string { b.WriteString(sectionLine("CANDIDATE BODY", false)) b.WriteString("\n") b.WriteString(indentBlock(ctx.Candidate.ResponseBody)) - return b.String() + return withScrollHint(b.String(), m.inspectBodyOffset, region.Height) } -func renderCompareRaw(ctx CompareContext) string { +func (m Model) renderCompareRaw(ctx CompareContext, region Region) string { var b strings.Builder b.WriteString(sectionLine("BASELINE", false)) b.WriteString("\n") - b.WriteString(renderRawResult(ctx.Baseline)) + b.WriteString(m.renderRawResult(ctx.Baseline)) b.WriteString("\n\n") b.WriteString(sectionLine("CANDIDATE", false)) b.WriteString("\n") - b.WriteString(renderRawResult(ctx.Candidate)) - return b.String() + b.WriteString(m.renderRawResult(ctx.Candidate)) + return withScrollHint(b.String(), m.inspectBodyOffset, region.Height) +} + +// withScrollHint clips content vertically (like scrollContent) and, when the +// content overflows, appends a muted hint showing the current scroll position +// on the final line so the operator knows the view is scrollable. +func withScrollHint(content string, offset, height int) string { + if height < 1 { + height = 1 + } + lines := strings.Split(content, "\n") + maxOffset := len(lines) - height + if maxOffset < 0 { + maxOffset = 0 + } + if maxOffset == 0 { + return scrollContent(content, offset, height) + } + // Only show the hint when there is room for both content and the hint + // line; otherwise fall back to plain scrolling to avoid overflow. + if height < 2 { + return scrollContent(content, offset, height) + } + // Reserve the last row for the hint. + scrolled := scrollContent(content, offset, height-1) + if offset < 0 { + offset = 0 + } + if offset > maxOffset { + offset = maxOffset + } + hint := fmt.Sprintf("↑/↓ scroll · %d/%d", offset+1, maxOffset+1) + return scrolled + "\n" + styleMuted.Render(hint) } -func renderRawResult(r model.Result) string { +func (m Model) renderRawResult(r model.Result) string { var b strings.Builder + b.WriteString(fmt.Sprintf(" %s %s\n", styleFieldName.Render("Request:"), m.requestNumber(r)+" · "+requestTime(r))) b.WriteString(fmt.Sprintf(" %s %d\n", styleFieldName.Render("Status:"), r.Status)) b.WriteString(fmt.Sprintf(" %s %s\n", styleFieldName.Render("Latency:"), formatDuration(r.Latency))) b.WriteString(fmt.Sprintf(" %s %s\n", styleFieldName.Render("Method:"), r.RequestMethod)) @@ -184,6 +266,42 @@ func renderRawResult(r model.Result) string { return strings.TrimRight(b.String(), "\n") } +// bodyRegion returns the vertical space available to the scrollable Body/Raw +// views after the fixed identity header. It never returns a non-positive +// height so the scroll helper always has valid bounds. +func bodyRegion(region Region, identity string) Region { + h := region.Height - (strings.Count(identity, "\n") + 1) - 2 + if h < 1 { + h = 1 + } + return Region{Width: region.Width, Height: h} +} + +// scrollContent clips content to height rows starting at the given scroll +// offset (clamped to valid bounds). Horizontal width is applied by the +// caller's region style; only vertical scrolling is performed here. +func scrollContent(content string, offset, height int) string { + if height < 1 { + height = 1 + } + lines := strings.Split(content, "\n") + if offset < 0 { + offset = 0 + } + maxOffset := len(lines) - height + if maxOffset < 0 { + maxOffset = 0 + } + if offset > maxOffset { + offset = maxOffset + } + end := offset + height + if end > len(lines) { + end = len(lines) + } + return strings.Join(lines[offset:end], "\n") +} + func indentBlock(s string) string { if s == "" { return " " + styleMuted.Render("(empty)") diff --git a/internal/tui/render_context.go b/internal/tui/render_context.go index 279dd54..d0a6f09 100644 --- a/internal/tui/render_context.go +++ b/internal/tui/render_context.go @@ -15,7 +15,7 @@ func (m Model) renderContextRegion(region Region) string { return "" // Compare workspace is itself the main region case m.workspace.mode == modeInspect && len(m.results) > 0: return m.renderInspectContext(region) - case m.workspace.compare.HasBaseline(): + case m.workspace.compare.HasBaseline() || m.workspace.compare.HasReference(): return m.renderComparePreview(region) case len(m.results) > 0: return m.renderObserveContext(region) @@ -30,20 +30,29 @@ func (m Model) renderComparePreview(region Region) string { b.WriteString(accentOrMuted("Comparison", true)) b.WriteString(gapSection) - b.WriteString(renderComparisonIdentityBlock(w.Baseline, w.Candidate)) + // Identity: baseline/candidate when a workspace is active, otherwise the + // reference request (persisted across runs) on its own. + if w.Baseline != nil || w.Candidate != nil { + b.WriteString(m.renderComparisonIdentityBlock(w.Baseline, w.Candidate)) + } else if w.Reference != nil { + b.WriteString(m.renderIdentityLine("●", "Pinned Baseline", *w.Reference)) + } if w.IsComparing() && w.Analysis != nil { b.WriteString("\n\n") b.WriteString(renderVerdict(w.Analysis)) } - // The preview is the collapsed Compare workspace: it must make orientation - // and the next action obvious even when the full workspace is not on screen. + // Context-specific keybindings make the next action obvious without leaving + // the drawer, including how to renounce a reference. b.WriteString("\n\n") - if w.IsComparing() { - b.WriteString(styleMuted.Render("c on ▶ to open · x clears")) - } else { - b.WriteString(styleMuted.Render("c on a result to compare")) + switch { + case w.IsComparing(): + b.WriteString(styleMuted.Render("c on ▶ open · x clear · s swap · [ ] view")) + case w.HasBaseline(): + b.WriteString(styleMuted.Render("c compare · x clear")) + case w.HasReference(): + b.WriteString(styleMuted.Render("c compare · x renounce")) } return regionStyle(region).Render(b.String()) diff --git a/internal/tui/render_inspect_why.go b/internal/tui/render_inspect_why.go index b01c673..6a5fc6b 100644 --- a/internal/tui/render_inspect_why.go +++ b/internal/tui/render_inspect_why.go @@ -7,7 +7,9 @@ import ( "github.com/divijg19/Pulse/internal/model" ) -var promotedKeys = map[string]bool{ +// promotedHeaderKeys are the headers surfaced prominently in the summary and +// therefore suppressed from the full header list in the WHY view. +var promotedHeaderKeys = map[string]bool{ "Content-Type": true, "Content-Encoding": true, "Content-Length": true, @@ -29,7 +31,7 @@ func (m Model) renderInspectWhy(result model.Result, maxLines int) string { sort.Strings(keys) lines := 0 for _, key := range keys { - if promotedKeys[key] { + if promotedHeaderKeys[key] { continue } if maxLines > 0 && lines >= maxLines { diff --git a/internal/tui/render_observe.go b/internal/tui/render_observe.go index 26e3229..127e11a 100644 --- a/internal/tui/render_observe.go +++ b/internal/tui/render_observe.go @@ -82,10 +82,7 @@ func (m Model) renderTimelineRow(index int, result model.Result, maxLatency time func (m Model) renderLogs(region Region) string { return m.renderResultList(region, "Logs · Sequence", "No events captured yet...", func(result model.Result, index int, selected bool, width int) string { - stamp := result.Timestamp.Format("15:04:05") - if result.Timestamp.IsZero() { - stamp = "--:--:--" - } + stamp := requestTime(result) method := m.effectiveMethod(result) reqURL := m.effectiveURL(result) @@ -101,7 +98,7 @@ func (m Model) renderLogs(region Region) string { } // resultCompareMarker returns the timeline marker for the result at index, -// applying deterministic priority: Candidate > Baseline > Pinned. Exactly one +// applying deterministic priority: Candidate > Baseline > Reference. Exactly one // marker is returned per entry. func (m Model) resultCompareMarker(index int) string { w := m.workspace.compare @@ -113,7 +110,7 @@ func (m Model) resultCompareMarker(index int) string { return "▶ " case w.Baseline != nil && resultsEqual(*w.Baseline, m.results[index]): return "◆ " - case w.PinnedBaseline != nil && resultsEqual(*w.PinnedBaseline, m.results[index]): + case w.Reference != nil && resultsEqual(*w.Reference, m.results[index]): return "● " } return "" diff --git a/internal/tui/update_compare.go b/internal/tui/update_compare.go index 5428019..1c5a51b 100644 --- a/internal/tui/update_compare.go +++ b/internal/tui/update_compare.go @@ -6,13 +6,12 @@ func (m Model) handleCompareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { w := &m.workspace.compare switch msg.String() { case "esc": - // Exit never destroys workflow state. - w.Exit() + // Esc never destroys workflow state. m.workspace.mode = modeObserve m.workspace.view = TimelineView return m, nil case "x": - // Clear ends the comparison but preserves the pinned baseline. + // Clear ends the comparison but preserves the reference. w.Clear() m.workspace.mode = modeObserve m.inspectBodyOffset = 0 diff --git a/internal/tui/update_inspect.go b/internal/tui/update_inspect.go index 34da4cb..29b226c 100644 --- a/internal/tui/update_inspect.go +++ b/internal/tui/update_inspect.go @@ -31,6 +31,8 @@ func (m Model) handleInspectKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "c": return m.handleMark() + case "x": + return m.handleRenounceOrClearKey() case "q", "ctrl+c": m.workspace.dialog = dialogConfirmQuit return m, nil diff --git a/internal/tui/view.go b/internal/tui/view.go index 4a890fb..8a3f886 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,7 +24,7 @@ const ( // colored highlight cell is identical for every workspace and flush against the // divider — the divider never moves when the workspace changes. var ribbonBadgeWidth = func() int { - labels := []string{"READY", "OBSERVE", "REQUEST", "INSPECT", "QUIT", "COMPARE", "EXECUTE"} + labels := []string{"READY", "OBSERVE", "REQUEST", "INSPECT", "QUIT", "COMPARE"} maxLen := 0 for _, l := range labels { if n := len(l); n > maxLen { @@ -299,6 +299,8 @@ func renderInteractionStatus(m Model) string { return "Comparing · " + compareViewNames[m.workspace.compare.View] case m.workspace.mode == modeObserve && m.workspace.compare.IsComparing(): return "Comparing · c on ▶ to open" + case m.workspace.mode == modeObserve && !m.workspace.compare.HasBaseline() && m.workspace.compare.HasReference(): + return "Reference · x renounces" case m.workspace.mode == modeObserve && m.workspace.compare.HasBaseline(): return "Baseline marked · c to compare" case m.workspace.mode == modeInspect: @@ -493,6 +495,9 @@ func (m Model) renderStatusline(state ShellState, width int) string { } func visibleWindow(total, selected, height int) int { + if height < 1 { + height = 1 + } if total <= height { return 0 } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index ed955d9..92327d4 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -33,7 +33,6 @@ func TestView_Running(t *testing.T) { m := NewModel() m.shell.Resize(100, 30) m.running = true - m.status = "RUNNING" m.startedAt = time.Now().Add(-2 * time.Second) m.elapsed = 2 * time.Second m.results = []model.Result{ @@ -657,7 +656,6 @@ func TestRenderTimeline_RunningEmpty(t *testing.T) { m := NewModel() m.shell.Resize(100, 24) m.running = true - m.status = "RUNNING" out := m.renderTimeline(Region{Width: 94, Height: 20}) if !contains(t, out, "Timeline") { @@ -702,7 +700,6 @@ func TestRenderLogs_RunningEmpty(t *testing.T) { m := NewModel() m.shell.Resize(100, 24) m.running = true - m.status = "RUNNING" out := m.renderLogs(Region{Width: 94, Height: 20}) if !contains(t, out, "Logs") { diff --git a/internal/tui/workspace.go b/internal/tui/workspace.go index d453486..8a66c40 100644 --- a/internal/tui/workspace.go +++ b/internal/tui/workspace.go @@ -49,7 +49,7 @@ var compareViewNames = [compareViewCount]string{ // CompareContext is the renderer-facing projection of a comparison. It contains // only presentation data. The renderer never knows whether Baseline originated -// from the current session or a pinned baseline — it simply renders what it is +// from the current session or a reference request — it simply renders what it is // given. Workflow details remain inside CompareWorkspace. type CompareContext struct { Baseline model.Result @@ -61,42 +61,36 @@ type CompareContext struct { // for workflow transitions; renderers never mutate it. Every transition is // expressed through exactly one operation method below. type CompareWorkspace struct { - Baseline *model.Result // current baseline (resolved value) - Candidate *model.Result // current candidate (resolved value) - PinnedBaseline *model.Result // persists across runs - State CompareState - View CompareView - Analysis *ComparisonAnalysis + Baseline *model.Result // current baseline (resolved value) + Candidate *model.Result // current candidate (resolved value) + Reference *model.Result // canonical reference request (survives runs) + State CompareState + View CompareView + Analysis *ComparisonAnalysis } // NewCompareWorkspace returns an empty CompareWorkspace. func NewCompareWorkspace() CompareWorkspace { return CompareWorkspace{ - Baseline: nil, - Candidate: nil, - PinnedBaseline: nil, - State: CompareIdle, - View: CompareViewOverview, - Analysis: nil, + Baseline: nil, + Candidate: nil, + Reference: nil, + State: CompareIdle, + View: CompareViewOverview, + Analysis: nil, } } // --- Predicates ------------------------------------------------------------ -// IsIdle reports whether no comparison workflow is active. -func (w CompareWorkspace) IsIdle() bool { return w.State == CompareIdle } - // HasBaseline reports whether a baseline result is available. func (w CompareWorkspace) HasBaseline() bool { return w.Baseline != nil } -// HasCandidate reports whether a candidate result is available. -func (w CompareWorkspace) HasCandidate() bool { return w.Candidate != nil } - // IsComparing reports whether both baseline and candidate are set. func (w CompareWorkspace) IsComparing() bool { return w.State == CompareComparing } -// HasPinnedBaseline reports whether a pinned baseline survives across runs. -func (w CompareWorkspace) HasPinnedBaseline() bool { return w.PinnedBaseline != nil } +// HasReference reports whether a reference request survives across runs. +func (w CompareWorkspace) HasReference() bool { return w.Reference != nil } // IsBaselineResult reports whether r is the current baseline. func (w CompareWorkspace) IsBaselineResult(r model.Result) bool { @@ -122,8 +116,8 @@ func (w *CompareWorkspace) MarkBaseline(r model.Result) { w.View = CompareViewOverview } -// Unmark clears the baseline and any candidate, returning to Idle. The pinned -// baseline is preserved. +// Unmark clears the baseline and any candidate, returning to Idle. The +// reference request is preserved. func (w *CompareWorkspace) Unmark() { w.Baseline = nil w.Candidate = nil @@ -165,7 +159,7 @@ func (w *CompareWorkspace) Swap() { } // Clear ends the active comparison session. Baseline, candidate and analysis -// are reset to zero; the pinned baseline survives. +// are reset to zero; the reference survives. func (w *CompareWorkspace) Clear() { w.Baseline = nil w.Candidate = nil @@ -173,9 +167,11 @@ func (w *CompareWorkspace) Clear() { w.Analysis = nil } -// Exit preserves the entire comparison so the operator can resume exactly where -// they left off. It performs no state mutation. -func (w *CompareWorkspace) Exit() {} +// RenounceReference clears the reference request, ending its persistence across runs. +// Only this operation removes the reference. +func (w *CompareWorkspace) RenounceReference() { + w.Reference = nil +} // NextView advances to the next comparison view, wrapping around. func (w *CompareWorkspace) NextView() {