From 5c235a6c8f7f959f1b41bd3f1aed7c9fc5eb8731 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 17:57:05 +0100 Subject: [PATCH 1/7] docs: scope contributor terminal redesign --- .../initiatives/WS-CLI-001/WS-CLI-001-50.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md new file mode 100644 index 000000000..4aafa9c3c --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -0,0 +1,89 @@ +# WS-CLI-001-50 — Readable contributor terminal workbench + +- Initiative: `WS-CLI-001` +- Durable disposition: `Planned` +- Intended merge outcome: Replace the contributor TUI presentation in place with visible navigation, readable work content and truthful evidence states. + +## Intent + +The human's 26 October design brief targets main `fcd32057`, which still matches +the inspected implementation. Existing contributor reads and verified downloads +work, but hidden tabs and flat metadata streams make instructions hard to find. +This is a presentation redesign, not the separate fixture prototype or a new +product capability. No overlapping production TUI PR was found during discovery. + +## Bounded change + +Allowed: `cli/internal/tui/`, focused existing `cli/tests/integration/test_tui*` +and native terminal observer support, `cli/README.md`, +`docs/design/cli-terminal-experience.md`, affected `docs/roadmap_status.md` claims, +and this initiative's records. Command terminal-capability plumbing may change +only if necessary for existing Bubble Tea background detection; no new config. + +Replace view composition with a measured shell, visible collection/section labels, +contextual footer, loaded-page observation line, and two panes only at >=100x24. +The right viewport owns scrolling while the retained left list preserves exact +selection. Plain mode stays single-column. A small style owner supplies semantic +slate/cyan/peach and readable light-terminal equivalents without dependencies. +Group existing detail values by the brief's reading order, retaining all exact +IDs, hashes and decimal strings. Uninspected previews use only loaded row facts. + +Keep the existing model, typed requests, generation/cancellation and staged +download owners. Reflow changes no selection and dispatches no request. A narrow +prose sanitizer may preserve LF paragraph boundaries after bearer suppression, +while escaping all other controls and keeping labels/IDs on the existing strict +sanitizer. Long confirmation content must remain scrollable without changing +the explicit destination -> Enter -> y publication decision. + +Prohibited: backend/API/client changes, new product mutations, polling, stale +caches, manager/review workspaces, credentials, shell execution, automatic viewers, +clipboard, new dependencies, release/CI weakening, compatibility implementations. +The existing design document becomes the single current presentation contract. + +## Acceptance criteria + +- Mine Overview at 120x40 and 80x24 prioritizes complete instructions and criteria; + terms remain potential, exact and separate for submission/review contributions. +- Collection and applicable section labels stay visible with one active label. + Resize and Unicode wrapping remain cell-bounded at 40x10 and above; below that + retain resize/quit guidance. Plain/no-color and light terminals remain readable. +- Row movement makes no requests; Enter performs the existing exact fresh read. + Findings/list continuations remain distinct one-page requests. Late responses, + denial and unavailable sessions cannot restore protected content. +- Guide metadata is not called verified until a successful verified download. + Confirmation includes exact target/destination/bytes/hash and explicit y; + cancellation, no-overwrite and cleanup behavior are unchanged. +- Stored outcomes are distinguished from findings; no inferred payment, authority, + acceptance or progress. Inert CLI arguments and explicit CLI contracts remain. +- Representative fixture terminal renders cover picker, lists, task/intake/guide, + submission/findings, empty/filter miss/denial/help at desktop, compact and plain + sizes. Render evidence is not live-backend evidence. + +## Risk and review routing + +L1: presentation of untrusted terminal text and exact download confirmations. +Plan review before implementation; final focused security, QA/test-delta, +architecture/reuse and documentation review on a clean pinned candidate. +No authorization policy or custody operation changes are authorized. + +## Verification + +Retain existing model/request/guide/hostile-text tests and real HTTP/PTY/public-API +journeys. Add focused layout/focus/multiline/ANSI/light/minimum-size regressions. +Use `go test ./...`, `go vet ./...`, focused race tests, and the existing isolated +runner for `test_tui_http.py`, `test_tui_native_proof.py`, `test_tui_public_api.py`. +Public-API tests use isolated PostgreSQL, not the user's project data. Run markdown +links, stale wording and diff checks. Exercise a discriminating layout or sanitizer +defect to demonstrate the new regression detects it. No new coverage quota. + +## Human review focus + +Find instructions immediately; identify project/collection/resource/section; +distinguish checker evidence from outcome; inspect exact locked context without +hashes crowding out prose; confirm coherent light/plain/compact presentation. + +## Evidence + +Discovery inspected existing view/model/request boundaries and current tests. +Baseline and brief match. Plan and implementation verification results are pending; +the record does not claim the redesign or its runtime proof is complete. From 42121c9ba2e2c02f17b4540ac9c7c2d1f3fa4c81 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 17:58:35 +0100 Subject: [PATCH 2/7] docs: bind confirmation to visible terminal layout --- .commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md index 4aafa9c3c..a0bab8b1a 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -34,6 +34,10 @@ prose sanitizer may preserve LF paragraph boundaries after bearer suppression, while escaping all other controls and keeping labels/IDs on the existing strict sanitizer. Long confirmation content must remain scrollable without changing the explicit destination -> Enter -> y publication decision. +Entering confirmation resets its viewport to the target heading. Existing scroll +keys can inspect its remaining fields; below 40x10, `y` cannot stage files or +dispatch a download while only resize guidance is visible. The retained worklist +is composed outside the scrolling detail viewport. Prohibited: backend/API/client changes, new product mutations, polling, stale caches, manager/review workspaces, credentials, shell execution, automatic viewers, @@ -53,6 +57,8 @@ The existing design document becomes the single current presentation contract. - Guide metadata is not called verified until a successful verified download. Confirmation includes exact target/destination/bytes/hash and explicit y; cancellation, no-overwrite and cleanup behavior are unchanged. + A confirmation resized below minimum cannot dispatch or stage on `y`; restoring + size preserves its exact target and still requires deliberate confirmation. - Stored outcomes are distinguished from findings; no inferred payment, authority, acceptance or progress. Inert CLI arguments and explicit CLI contracts remain. - Representative fixture terminal renders cover picker, lists, task/intake/guide, From f2efca9965360a165d65638a211b5405fcddd854 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 18:14:13 +0100 Subject: [PATCH 3/7] feat(cli): redesign contributor terminal reading experience --- .../initiatives/WS-CLI-001/OVERVIEW.md | 6 +- .../initiatives/WS-CLI-001/WS-CLI-001-50.md | 23 +- cli/README.md | 41 +- .../tui/confirmation_visibility_test.go | 46 ++ cli/internal/tui/details.go | 446 ++++++++++++ cli/internal/tui/details_layout_test.go | 142 ++++ cli/internal/tui/guide_findings_test.go | 18 +- cli/internal/tui/model.go | 18 +- cli/internal/tui/render_evidence_test.go | 147 ++++ cli/internal/tui/shell_layout_test.go | 158 +++++ cli/internal/tui/styles.go | 30 + cli/internal/tui/text.go | 19 + cli/internal/tui/view.go | 582 +++++++-------- cli/tests/integration/test_tui_http.py | 88 ++- cli/tests/integration/test_tui_public_api.py | 12 +- cli/tests/integration/tui_terminal_support.py | 8 +- docs/design/cli-terminal-experience.md | 667 +++++------------- docs/roadmap_status.md | 29 +- 18 files changed, 1633 insertions(+), 847 deletions(-) create mode 100644 cli/internal/tui/confirmation_visibility_test.go create mode 100644 cli/internal/tui/details.go create mode 100644 cli/internal/tui/details_layout_test.go create mode 100644 cli/internal/tui/render_evidence_test.go create mode 100644 cli/internal/tui/shell_layout_test.go create mode 100644 cli/internal/tui/styles.go diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 4f86d7114..8cc838d39 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -63,6 +63,9 @@ workbench: exact-grant projects, one-page Ready/active Mine/own Submissions, fresh details and explicit Mine context/intake reads. [WS-CLI-001-49](WS-CLI-001-49.md) adds exact-Submission findings pages and ordered locked-guide selection with explicit verified single-document download. +[WS-CLI-001-50](WS-CLI-001-50.md) replaces their contributor presentation with +visible collections/sections, readable instructions and truthful page/evidence +states; it changes no public operation or lifecycle authority. Contribution evidence remains an inert CLI handoff. Product mutations, management screens and operation-specific recovery remain separate TUI boundaries; this interface does not depend on first binary publication or system-wide discovery. @@ -426,7 +429,8 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. Hosted and human-review journey proof remain separate. Unfinished human review/revision is not current CLI implementation scope. 26. **Optional TUI:** -48 supplies contributor navigation through existing typed - reads; -49 adds findings pages and confirmed verified guide downloads. + reads; -49 adds findings pages and confirmed verified guide downloads; -50 + redraws this same contributor boundary for readable work and evidence states. In-TUI Evidence, product writes and management remain separate boundaries; Evidence first needs a shared typed single-page client port without changing complete CLI exports. Never require a TUI for agents diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md index a0bab8b1a..3bd65de43 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -72,6 +72,13 @@ Plan review before implementation; final focused security, QA/test-delta, architecture/reuse and documentation review on a clean pinned candidate. No authorization policy or custody operation changes are authorized. +The cohesive diff exceeds the preferred small-change line budget: the existing +detail renderer moves out of view.go while being regrouped, the obsolete mixed +current/future design document is replaced, and presentation/terminal regressions +cover the shared shell across existing screens. Splitting would leave competing +renderers or inconsistent navigation. Requests, API, download custody, dependencies +and workflows are unchanged; focused reviewers inspect these separate risks. + ## Verification Retain existing model/request/guide/hostile-text tests and real HTTP/PTY/public-API @@ -90,6 +97,16 @@ hashes crowding out prose; confirm coherent light/plain/compact presentation. ## Evidence -Discovery inspected existing view/model/request boundaries and current tests. -Baseline and brief match. Plan and implementation verification results are pending; -the record does not claim the redesign or its runtime proof is complete. +Discovery inspected existing view/model/request boundaries and current tests; +baseline and brief match. Plan review identified hidden confirmation after resize. +`TestHiddenConfirmationCannotStageOrDispatch` failed on the pre-fix owner because +`y` staged a download while only resize guidance was visible, then passed after +the size guard. The same test restores the exact confirmation and verifies the +deliberate positive publication. A native terminal regression covers this boundary. + +The old flat Guide assertion is replaced by server-order rows plus exact selected +metadata before and after selection. Finding assertions retain every prior value +in the new grouping. Native resize proof scrolls to exact references and back to +instructions rather than requiring both to occupy the initial viewport. No test +is removed or skipped. Representative renders are fixtures, not live product +proof; native socket and signed-API/PostgreSQL journeys remain distinct checks. diff --git a/cli/README.md b/cli/README.md index ff3ae588a..9a0b77524 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,41 +27,56 @@ non-terminal input/output and JSON mode before making an API request. Start with your exact-grant projects, then browse **Ready**, **Mine** (active assignments) and **Submissions**. Enter opens fresh contributor-safe task or owned Submission detail. Active Mine details offer Context, Intake and Guide -sections. Submission detail also offers paged Findings. Each list fetches one page; filtering searches only that loaded -page. Project membership, terms and action hints are observations, not authority -for a later operation. Workstream rechecks each public read. +sections after Overview. Submission detail offers Overview and paged Findings. +The header keeps the project, collection and active section visible. At +100×24 or larger, a stable left worklist and scrollable right detail pane keep +long instructions and acceptance criteria readable. Guide confirmation replaces +both panes with a scrollable exact-target review; smaller usable terminals +show one pane at a time, and `--plain` is always single-column. Below 40×10, +the workspace shows only resize/quit guidance and cannot confirm a download. +An unopened row previews only loaded-page facts; Enter makes a fresh exact read. +Each list fetches one page, and filtering searches only that loaded page. The +status line labels loaded rows, page/continuation and local observation time, +not a total or a server-wide search. Project membership, potential locked terms +and action hints are observations, not authority for a later operation. +Instructions and criteria lead task detail; exact IDs, hashes and decimal +terms remain available separately. Workstream rechecks each public read. | Key | Action | |---|---| -| Arrows / Enter | Select, scroll and inspect | +| Arrows / Enter | Select a row or scroll detail; Enter inspects freshly | | Tab / Esc | Change section / go back or cancel | | `g` | Fetch the project picker again | | `n` / `r` | Fetch the next page / refresh the current view | | `/` | Filter loaded names/titles locally; Enter finishes and Esc cancels | | `c` / `?` | Show inert CLI arguments / keyboard help | -| `d` in Guide | Enter an existing download directory; Enter reviews the target, `y` confirms | +| `d` in Guide | Enter an existing download directory; Enter reviews the scrollable exact target, `y` confirms | | `q` / Ctrl+C | Quit and cancel outstanding client reads; `q` is text during text entry, Ctrl+C always quits | The workspace makes no product mutation requests: it does not claim/start work, prepare or submit ZIPs, export records, poll, acquire tokens or save local history. Guide metadata is not proof of downloaded-byte integrity. In Guide, -select one document, enter an existing real directory and confirm the exact -target. Verified bytes are published under the document's UUID filename without -overwriting an existing file. Cancelled or failed downloads are not published. +select one document, enter an existing real directory and confirm its label, +document/task IDs, path, UUID filename, byte count and hash. Verified bytes are +published under that filename without overwriting an existing file. Cancelled +or failed downloads are not published. PDF/Office documents are downloaded, not rendered or opened automatically. Findings display each returned run's identity, current flag and checker evidence; -they do not imply acceptance. `n` fetches one next findings page. +they do not imply the stored Submission outcome or acceptance. `n` fetches one +next findings page. Contribution facts, available through the explicit CLI, +do not establish payment or fulfillment. Contribution evidence remains a manual CLI handoff: use `contributions --project PROJECT_ID`. Displayed arguments never execute or copy automatically. A denial clears affected content; expired authentication clears the session's -work data. Network failure is shown as unavailable, never as an empty queue or -successful operation. Refresh is deliberate. No background request retries or -write recovery occur. The profile read may perform Workstream's existing actor +work data. A failed current read clears its content and continuation and is +shown as unavailable, never as an empty queue or successful operation. Refresh +is deliberate. No background request retries or write recovery occur. The +profile read may perform Workstream's existing actor admission, last-seen and audit effects; “read-only” does not mean no server-side effects. This local interface does not establish a deployed service, payment, -or completion of the broader [terminal design](../docs/design/cli-terminal-experience.md). +or future write/management workflows. See the [terminal presentation contract](../docs/design/cli-terminal-experience.md). ## Explicit commands diff --git a/cli/internal/tui/confirmation_visibility_test.go b/cli/internal/tui/confirmation_visibility_test.go new file mode 100644 index 000000000..22e52b0b7 --- /dev/null +++ b/cli/internal/tui/confirmation_visibility_test.go @@ -0,0 +1,46 @@ +package tui + +import ( + "context" + "os" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func TestHiddenConfirmationCannotStageOrDispatch(t *testing.T) { + doc := api.TaskGuideDocument{DocumentID: actorID, Label: "Exact original", MediaType: "text/markdown", ByteCount: 5, SHA256: "sha256:" + strings.Repeat("a", 64)} + f := &fakeClient{context: func(context.Context, string) (api.Result[api.WorkContext], error) { + return api.Result[api.WorkContext]{Value: guideContext(doc)}, nil + }} + m := openMineGuide(t, f) + t.Cleanup(m.Close) + directory := t.TempDir() + press(t, m, "d") + press(t, m, directory) + press(t, m, "enter") + before := len(f.calls) + m.Update(tea.WindowSizeMsg{Width: 30, Height: 8}) + _, cmd := m.Update(keyMessage("y")) + if cmd != nil || m.download != nil || m.loading || len(f.calls) != before { + t.Fatal("hidden confirmation staged or dispatched a download") + } + entries, err := os.ReadDir(directory) + if err != nil || len(entries) != 0 { + t.Fatal("hidden confirmation touched destination", entries, err) + } + m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if m.guideMode != guideConfirm || m.guideDirectory != directory || m.context.GuideDocuments[m.guideSelected].DocumentID != doc.DocumentID { + t.Fatal("resize lost exact confirmation target") + } + if !strings.Contains(m.View().Content, "Confirm download") { + t.Fatal("restored confirmation is not visible") + } + press(t, m, "y") + entries, err = os.ReadDir(directory) + if err != nil || len(entries) != 1 || entries[0].Name() != doc.Filename() { + t.Fatal("visible deliberate confirmation did not publish exact original", entries, err) + } +} diff --git a/cli/internal/tui/details.go b/cli/internal/tui/details.go new file mode 100644 index 000000000..25c85c82d --- /dev/null +++ b/cli/internal/tui/details.go @@ -0,0 +1,446 @@ +package tui + +import ( + "fmt" + "math/big" + "strings" + + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func number(v *big.Int) string { + if v == nil { + return "not supplied" + } + return v.String() +} + +func humanBytes(value int64) string { + if value < 1024 { + return fmt.Sprintf("%d B", value) + } + for _, unit := range []struct { + bytes int64 + name string + }{{1024 * 1024 * 1024, "GiB"}, {1024 * 1024, "MiB"}, {1024, "KiB"}} { + if value >= unit.bytes { + return fmt.Sprintf("%.1f %s", float64(value)/float64(unit.bytes), unit.name) + } + } + return fmt.Sprintf("%d B", value) +} + +type detailWriter struct { + m *Model + out strings.Builder +} + +func (d *detailWriter) heading(label string) { + if d.out.Len() > 0 { + d.out.WriteByte('\n') + } + d.out.WriteString(d.m.styles().heading.Render(label)) + d.out.WriteByte('\n') +} + +func (d *detailWriter) line(label, value string) { + d.out.WriteString(label + ": " + d.m.clean(value) + "\n") +} + +func (d *detailWriter) optional(label string, value *string) { + if value != nil { + d.line(label, *value) + } +} + +func (d *detailWriter) prose(label, value string) { + d.out.WriteString(label + "\n" + d.m.prose(value) + "\n") +} + +func (d *detailWriter) optionalProse(label string, value *string) { + if value != nil { + d.prose(label, *value) + } +} + +func (d *detailWriter) note(value string) { d.out.WriteString(value + "\n") } +func (d *detailWriter) String() string { return strings.TrimRight(d.out.String(), "\n") } + +func (m *Model) detailText() string { + if m.tab == submissions && m.section == 1 && m.findings != nil { + return m.findingsText() + } + if m.submission != nil { + return m.submissionText() + } + if m.section == 3 && m.context != nil { + return m.guideText() + } + if m.section == 1 && m.context != nil { + return m.contextText() + } + if m.section == 2 && m.intake != nil { + return m.intakeText() + } + if m.task != nil { + return m.taskText() + } + return "No detail loaded." +} + +func (m *Model) taskText() string { + v := m.task + d := detailWriter{m: m} + d.heading(m.clean(v.Title)) + d.line("Stored status", v.Status) + if len(v.SkillTags) > 0 { + d.line("Skills", strings.Join(v.SkillTags, ", ")) + } + d.heading("Instructions") + d.note(m.prose(v.Description)) + if v.AcceptanceCriteria != nil { + d.heading("Acceptance criteria") + d.note(m.prose(*v.AcceptanceCriteria)) + } + if v.RejectionCriteria != nil { + d.heading("Rejection criteria") + d.note(m.prose(*v.RejectionCriteria)) + } + d.heading("Contribution terms") + for _, terms := range []struct { + label string + raw []byte + }{{"Accepted submission terms", v.Compensation.AcceptedSubmission}, {"Completed review terms", v.Compensation.CompletedReview}} { + awards, ok := api.CompensationAwards(terms.raw) + if !ok { + d.line(terms.label, "unavailable") + continue + } + if len(awards) == 0 { + d.line(terms.label, "unpaid") + } + for _, award := range awards { + d.line(terms.label, award.Instrument+" / "+award.Unit+" / "+award.Quantity) + } + } + d.note("Terms are potential earned terms, not acceptance or fulfillment.") + d.note("No action can be sent from this workbench.") + d.heading("References") + d.line("Task", v.TaskID) + d.line("Project", v.ProjectID) + d.line("Contribution policy version", v.Compensation.ContributionPolicyVersionID) + return d.String() +} + +func (m *Model) contextText() string { + v := m.context + d := detailWriter{m: m} + d.heading("Assignment") + d.line("Task", v.Task.TaskID) + d.line("Project", v.Project.ID) + d.line("Assigned to current actor", fmt.Sprint(v.Lifecycle.AssignedToCurrentActor)) + d.line("Observed action hints (not authority)", strings.Join(v.Lifecycle.NextActions, ", ")) + d.heading("Locked rules") + d.line("Guide", v.Guide.ID) + d.line("Locked guide version", v.Guide.Version) + d.line("Review policy", v.ReviewPolicy.PolicyID) + d.line("Review policy generation", fmt.Sprint(v.ReviewPolicy.Generation)) + d.line("Review policy hash", v.ReviewPolicy.PolicyHash) + d.line("Revision policy", v.RevisionPolicy.PolicyID) + d.line("Revision policy generation", fmt.Sprint(v.RevisionPolicy.Generation)) + d.line("Revision policy hash", v.RevisionPolicy.PolicyHash) + d.line("Contribution policy version", v.ContributionPolicyVersionID) + d.heading("Guide originals (server metadata; bytes not downloaded)") + if len(v.GuideDocuments) == 0 { + d.note("No assigned guide documents.") + } + for _, original := range v.GuideDocuments { + d.line("Document label", original.Label) + d.line("Document ID", original.DocumentID) + d.line("Order", fmt.Sprint(original.Order)) + d.line("Media type", original.MediaType) + d.line("Bytes", fmt.Sprint(original.ByteCount)) + d.line("SHA-256 (server metadata; bytes not downloaded)", original.SHA256) + d.line("Read reference (server metadata; not opened)", original.ReadReference) + } + if v.RevisionContext != nil { + d.heading("Revision context") + r := v.RevisionContext + d.line("Revision preparation", r.PreparationID) + d.line("Revision preparation digest", r.PreparationSHA256) + d.line("Revision source Submission", r.SourceSubmissionID) + d.line("Revision source routing manifest", r.SourceRoutingManifestID) + d.optional("Predecessor preparation", r.PredecessorPreparationID) + d.line("Revision sequence", fmt.Sprint(r.Sequence)) + d.line("Next revision round", fmt.Sprint(r.NextRevisionRound)) + d.line("Maximum revision rounds", fmt.Sprint(r.MaxRevisionRounds)) + d.line("Revision requested", r.RequestedAt) + d.line("Revision outcome", r.Outcome) + d.line("Revision disposition", r.Disposition) + d.line("Revision clock", r.ClockState) + d.optional("Revision deadline", r.DeadlineAt) + if r.RemainingMicroseconds != nil { + d.line("Remaining microseconds", fmt.Sprint(*r.RemainingMicroseconds)) + } + d.optionalProse("Direction", r.Direction) + d.line("Prior guide", r.Prior.GuideID) + if r.Target != nil { + d.line("Target guide", r.Target.GuideID) + } + for _, change := range r.Changes { + d.line("Changed component", change.Component) + d.line("Before", change.Before) + d.line("After", change.After) + } + } + return d.String() +} + +func (m *Model) intakeText() string { + v := m.intake + d := detailWriter{m: m} + d.heading("Required packet fields") + if len(v.RequiredPacketFields) == 0 { + d.note("None listed.") + } else { + for _, field := range v.RequiredPacketFields { + d.line("Field", field) + } + } + d.heading("Required artifacts") + if len(v.RequiredArtifacts) == 0 { + d.note("None listed.") + } + for _, a := range v.RequiredArtifacts { + d.line("Artifact", a.Key) + d.line("Path (text only)", a.Path) + d.line("Required", fmt.Sprint(a.Required)) + d.line("Hash required", fmt.Sprint(a.HashRequired)) + d.optionalProse("Description", a.Description) + } + d.heading("Required evidence") + if len(v.RequiredEvidence) == 0 { + d.note("None listed.") + } + for _, e := range v.RequiredEvidence { + d.line("Evidence", e.Key) + d.line("Label", e.Label) + d.line("Required", fmt.Sprint(e.Required)) + d.line("Hash required", fmt.Sprint(e.HashRequired)) + d.optionalProse("Description", e.Description) + } + d.heading("Forbidden artifacts") + if len(v.ForbiddenArtifacts) == 0 { + d.note("None listed.") + } + for _, a := range v.ForbiddenArtifacts { + d.line("Forbidden pattern", a.Pattern) + d.optionalProse("Reason", a.Reason) + d.optionalProse("Suggested fix", a.WorkerFacingFix) + d.optional("Severity", a.Severity) + } + d.heading("Attestations") + if len(v.AttestationTerms) == 0 { + d.note("None listed.") + } + for _, term := range v.AttestationTerms { + d.line("Attestation term", term) + } + d.heading("Package and storage constraints") + d.line("Manifest required", fmt.Sprint(v.ManifestRequired)) + d.line("Artifact hash required", fmt.Sprint(v.ArtifactHashRequired)) + d.line("Hash algorithm", v.ArtifactHashAlgorithm) + d.line("Allowed storage schemes", strings.Join(v.AllowedStorageSchemes, ", ")) + d.line("Allowed URI prefixes (text only)", strings.Join(v.StorageReferenceRules.AllowedURIPrefixes, ", ")) + d.line("Storage credentials allowed", fmt.Sprint(v.StorageReferenceRules.CredentialsAllowed)) + d.line("Storage query strings allowed", fmt.Sprint(v.StorageReferenceRules.QueryStringsAllowed)) + d.line("Storage fragments allowed", fmt.Sprint(v.StorageReferenceRules.FragmentsAllowed)) + d.line("Storage path traversal allowed", fmt.Sprint(v.StorageReferenceRules.PathTraversalAllowed)) + d.line("Maximum file bytes", number(v.MaximumFileSizeBytes)) + d.line("Maximum package bytes", number(v.MaximumPackageSizeBytes)) + d.line("Maximum archive bytes", number(v.MaximumArchiveSizeBytes)) + d.line("Maximum archive entries", number(v.MaximumArchiveEntries)) + d.line("Package required", fmt.Sprint(v.Packaging.PackageRequired)) + d.line("Allowed package formats", strings.Join(v.Packaging.AllowedPackageFormats, ", ")) + d.heading("References") + d.line("Task", v.TaskID) + d.line("Project", v.ProjectID) + d.line("Locked guide version", v.GuideVersion) + d.optional("Policy schema version", v.PolicySchemaVersion) + d.optional("Merge algorithm version", v.MergeAlgorithmVersion) + d.note("Intake requirements are not a preparation or Submission. No package is uploaded.") + return d.String() +} + +func (m *Model) guideText() string { + v := m.context + d := detailWriter{m: m} + if m.guideMode == guideDestination { + d.heading("Download destination") + if m.guideSelected >= 0 && m.guideSelected < len(v.GuideDocuments) { + d.line("Document", v.GuideDocuments[m.guideSelected].DocumentID) + d.line("Label", v.GuideDocuments[m.guideSelected].Label) + } + d.line("Download directory", m.guideDirectory) + d.note("Enter confirms this existing directory; no directory is created.") + return d.String() + } + if m.guideMode == guideConfirm && m.guideSelected >= 0 && m.guideSelected < len(v.GuideDocuments) { + original := v.GuideDocuments[m.guideSelected] + d.heading("Confirm download") + d.line("Label", original.Label) + d.line("Document", original.DocumentID) + d.line("Task", v.Task.TaskID) + d.line("Download directory", m.guideDirectory) + d.line("Filename", original.Filename()) + d.line("Expected bytes", fmt.Sprint(original.ByteCount)) + d.line("Expected SHA-256", original.SHA256) + d.note("y downloads and verifies bytes before saving | esc cancels") + return d.String() + } + if m.guideNotice != "" { + if strings.HasPrefix(m.guideNotice, "Downloaded: ") { + d.note(m.clean(m.guideNotice) + " (verified and saved)") + } else { + d.note(m.clean(m.guideNotice)) + } + } + if len(v.GuideDocuments) > 0 && m.guideSelected >= 0 && m.guideSelected < len(v.GuideDocuments) { + original := v.GuideDocuments[m.guideSelected] + d.heading("Selected original (server metadata; bytes not downloaded)") + d.line("Label", original.Label) + d.line("Document ID", original.DocumentID) + d.line("Order", fmt.Sprint(original.Order)) + d.line("Media type", original.MediaType) + d.line("Bytes (server metadata; bytes not downloaded)", fmt.Sprint(original.ByteCount)) + d.line("SHA-256 (server metadata; bytes not downloaded)", original.SHA256) + d.line("Read reference (server metadata; not opened)", original.ReadReference) + } + d.heading("Guide originals (server metadata; bytes not downloaded)") + if len(v.GuideDocuments) == 0 { + d.note("No assigned guide documents.") + } + for index, original := range v.GuideDocuments { + mark := " " + if index == m.guideSelected { + mark = "> " + } + d.note(mark + fmt.Sprint(index+1) + ". " + m.clean(original.Label) + " · " + m.clean(original.MediaType) + " · " + humanBytes(original.ByteCount)) + } + d.heading("References") + d.line("Task", v.Task.TaskID) + d.line("Project", v.Project.ID) + d.line("Guide", v.Guide.ID) + d.line("Locked guide version", v.Guide.Version) + d.note("Guide metadata is not verified downloaded bytes.") + return d.String() +} + +func (m *Model) submissionText() string { + v := m.submission + d := detailWriter{m: m} + title := v.Summary + if strings.TrimSpace(title) == "" { + title = "Submission" + } + d.heading(m.prose(title)) + d.heading("Stored outcome") + d.line("Stored outcome", v.Status) + d.optional("Reason", v.StatusReason) + d.heading("Finding counts") + d.line("Blocking findings", number(v.FindingCounts.Blocking)) + d.line("Warning findings", number(v.FindingCounts.Warning)) + d.line("Coverage incomplete", number(v.FindingCounts.CoverageIncomplete)) + d.heading("Attempt") + d.line("Version", number(v.Version)) + d.optional("Predecessor", v.Predecessor) + d.heading("Timing") + d.line("Submitted", v.SubmittedAt) + d.optional("Locked", v.LockedAt) + d.optional("Decided", v.DecidedAt) + d.heading("Evidence") + if len(v.Evidence) == 0 { + d.note("No evidence items listed.") + } + for _, e := range v.Evidence { + d.line("Evidence label", e.Label) + d.line("Type", e.Type) + d.line("Size", number(e.Size)) + d.line("Evidence ID", e.ID) + } + d.heading("References") + d.line("Submission", v.ID) + d.line("Task", v.Task) + d.note("Tab opens contributor-visible checker findings. Stored observations do not predict acceptance or fulfillment.") + return d.String() +} + +func (m *Model) findingsText() string { + d := detailWriter{m: m} + if len(m.findings.Items) == 0 { + d.heading("Checker runs") + d.note("No checker runs on this fetched page. This is not a passing result.") + } + for _, run := range m.findings.Items { + label := "Historical checker run" + if run.Current { + label = "Current checker run" + } + d.heading(label) + d.line("Run status", run.Status) + d.line("Current", fmt.Sprint(run.Current)) + d.line("Attempt", number(run.Attempt)) + d.line("Submission version", number(run.Version)) + d.line("Created", run.CreatedAt) + if run.CompletedAt == nil { + d.line("Completed", "not supplied") + } else { + d.line("Completed", *run.CompletedAt) + } + d.line("Run", run.ID) + d.line("Submission", run.Submission) + d.line("Task", run.Task) + if run.Predecessor == nil { + d.line("Predecessor run", "not supplied") + } else { + d.line("Predecessor run", *run.Predecessor) + } + if len(run.Results) == 0 { + d.note("No findings in this run; this does not establish a pass.") + } + for _, finding := range run.Results { + d.note("") + d.line("Severity", finding.Severity) + d.line("Finding status", finding.Status) + d.line("Checker", finding.Checker) + if finding.CheckID == nil { + d.line("Check ID", "not supplied") + } else { + d.line("Check ID", *finding.CheckID) + } + if finding.Message == nil { + d.line("Message", "not supplied") + } else { + d.prose("Message", *finding.Message) + } + if finding.Path == nil { + d.line("Path", "not supplied") + } else { + d.line("Path", *finding.Path) + } + if finding.Line == nil { + d.line("Line", "not supplied") + } else { + d.line("Line", fmt.Sprint(*finding.Line)) + } + if finding.Fix == nil { + d.line("Suggested fix", "not supplied") + } else { + d.prose("Suggested fix", *finding.Fix) + } + d.line("Finding", finding.ID) + } + } + d.note("Checker findings are evidence; the stored submission outcome determines disposition.") + return d.String() +} diff --git a/cli/internal/tui/details_layout_test.go b/cli/internal/tui/details_layout_test.go new file mode 100644 index 000000000..3b8a1e6f2 --- /dev/null +++ b/cli/internal/tui/details_layout_test.go @@ -0,0 +1,142 @@ +package tui + +import ( + "math/big" + "strings" + "testing" + + "github.com/Flow-Research/workstream/cli/internal/api" +) + +func orderOf(t *testing.T, rendered string, labels ...string) { + t.Helper() + last := -1 + for _, label := range labels { + at := strings.Index(rendered, label) + if at < 0 || at <= last { + t.Fatalf("missing or out-of-order %q in:\n%s", label, rendered) + } + last = at + } +} + +func TestProsePreservesParagraphsAfterBearerSuppression(t *testing.T) { + m := &Model{token: "bearer\r\nsecret"} + rendered := m.prose("First\r\n\r\nbearer\r\nsecret\nLast\x1b]52;c;secret\a\u202e\u009b\t") + if !strings.HasPrefix(rendered, "First\n\n[redacted]\nLast") { + t.Fatal(rendered) + } + for _, unsafe := range []string{"bearer", "\r", "\x1b", "\a", "\u202e", "\u009b", "\t"} { + if strings.Contains(rendered, unsafe) { + t.Fatalf("unsafe %q in %q", unsafe, rendered) + } + } + if !strings.Contains(rendered, "\\u001B") || !strings.Contains(rendered, "\\u202E") { + t.Fatal(rendered) + } + if got := m.clean("a\nb"); got != "a\\u000Ab" { + t.Fatalf("metadata sanitizer changed: %q", got) + } +} + +func TestTaskOverviewPrioritizesFullProseAndExactTerms(t *testing.T) { + accept, reject := "Accepted line\n\nSecond paragraph", "Rejected line\nNext line" + task := taskDetail() + task.Title = "A complete contributor title" + task.Description = "Do the work\n\nThen submit exact artifact" + task.AcceptanceCriteria, task.RejectionCriteria = &accept, &reject + task.SkillTags = []string{"Go", "writing"} + task.Compensation.AcceptedSubmission = []byte(`[{"instrument":"money","unit":"USD","quantity":"0.100000000000000001"}]`) + task.Compensation.CompletedReview = []byte(`"unpaid"`) + m := &Model{task: &task, noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "A complete contributor title", "Stored status: ready", "Skills: Go, writing", "Instructions", "Do the work\n\nThen submit exact artifact", "Acceptance criteria", "Accepted line\n\nSecond paragraph", "Rejection criteria", "Contribution terms", "Accepted submission terms: money / USD / 0.100000000000000001", "Completed review terms: unpaid", "References", "Task: "+taskID) + for _, want := range []string{"Project: " + projectID, "Contribution policy version: " + policyID, "potential earned terms, not acceptance or fulfillment"} { + if !strings.Contains(rendered, want) { + t.Fatal(want, rendered) + } + } +} + +func TestGuideConfirmationReplacesDocumentListAndNamesExactTarget(t *testing.T) { + hash := "sha256:" + strings.Repeat("a", 64) + doc := api.TaskGuideDocument{DocumentID: actorID, Order: 9, Label: "Read me", MediaType: "text/markdown", ByteCount: 128, SHA256: hash} + context := guideContext(doc) + m := &Model{context: &context, section: 3, guideMode: guideConfirm, guideDirectory: "/tmp/selected", noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "Confirm download", "Label: Read me", "Document: "+actorID, "Task: "+taskID, "Download directory: /tmp/selected", "Filename: "+doc.Filename(), "Expected bytes: 128", "Expected SHA-256: "+hash) + if strings.Contains(rendered, "Guide originals") || strings.Contains(rendered, "verified and saved") { + t.Fatal(rendered) + } + m.guideMode, m.guideNotice = guideBrowse, "Unavailable: transfer failed" + if strings.Contains(m.detailText(), "verified and saved") { + t.Fatal(m.detailText()) + } +} + +func TestGuideBrowseKeepsServerOrderAndSelectedExactMetadata(t *testing.T) { + firstHash := "sha256:" + strings.Repeat("a", 64) + secondHash := "sha256:" + strings.Repeat("b", 64) + first := api.TaskGuideDocument{DocumentID: actorID, Order: 2, Label: "First", MediaType: "text/markdown", ByteCount: 1024, SHA256: firstHash} + second := api.TaskGuideDocument{DocumentID: policyID, Order: 7, Label: "Second", MediaType: "application/pdf", ByteCount: 2048, SHA256: secondHash} + context := guideContext(first, second) + m := &Model{context: &context, section: 3, noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "Selected original", "Document ID: "+actorID, "Order: 2", firstHash, "Guide originals", "> 1. First · text/markdown · 1.0 KiB", " 2. Second · application/pdf · 2.0 KiB") + if strings.Contains(rendered, secondHash) { + t.Fatal("unselected original crowded selected detail", rendered) + } + m.guideSelected = 1 + rendered = m.detailText() + orderOf(t, rendered, "Document ID: "+policyID, "Order: 7", secondHash, "Guide originals", " 1. First", "> 2. Second") + for _, want := range []string{"Task: " + taskID, "Project: " + projectID, "Guide: " + actorID, "Locked guide version: locked-v1"} { + if !strings.Contains(rendered, want) { + t.Fatal(want, rendered) + } + } +} + +func TestIntakeDisplaysConstraintsWithoutValidationClaim(t *testing.T) { + desc, reason, fix := "Expected artifact", "Secrets forbidden", "Remove credentials" + value := api.SubmissionRequirements{ + TaskID: taskID, ProjectID: projectID, GuideVersion: "locked-v1", + RequiredPacketFields: []string{"summary"}, + RequiredArtifacts: []api.ArtifactRequirement{{Key: "source", Path: "src/**", Required: true, HashRequired: true, Description: &desc}}, + RequiredEvidence: []api.EvidenceRequirement{{Key: "test", Label: "Tests", Required: true, HashRequired: false}}, + ForbiddenArtifacts: []api.ForbiddenArtifact{{Pattern: "*.key", Reason: &reason, WorkerFacingFix: &fix}}, + AttestationTerms: []string{"original work"}, ManifestRequired: true, ArtifactHashRequired: true, + ArtifactHashAlgorithm: "sha256", AllowedStorageSchemes: []string{"s3"}, + StorageReferenceRules: api.StorageReferenceRules{AllowedURIPrefixes: []string{"s3://bucket/"}}, + MaximumFileSizeBytes: big.NewInt(4294967297), Packaging: api.PackagingRequirements{PackageRequired: true, AllowedPackageFormats: []string{"zip"}}, + } + m := &Model{intake: &value, section: 2, noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "Required packet fields", "Field: summary", "Required artifacts", "Artifact: source", "Required evidence", "Evidence: test", "Forbidden artifacts", "Forbidden pattern: *.key", "Attestations", "Attestation term: original work", "Package and storage constraints", "Maximum file bytes: 4294967297", "References") + for _, want := range []string{"Hash required: true", "Path (text only): src/**", "Reason\nSecrets forbidden", "Suggested fix\nRemove credentials", "Package required: true", "Allowed package formats: zip", "Policy schema version"} { + if want == "Policy schema version" { + if strings.Contains(rendered, want) { + t.Fatal(rendered) + } + continue + } + if !strings.Contains(rendered, want) { + t.Fatal(want, rendered) + } + } +} + +func TestSubmissionAndFindingsSeparateOutcomeFromEvidence(t *testing.T) { + reason := "coverage_incomplete" + submission := api.SubmissionHistory{ID: submissionID, Task: taskID, Status: "needs_revision", StatusReason: &reason, Summary: "My full summary", Version: big.NewInt(12), FindingCounts: api.SubmissionFindingCounts{Blocking: big.NewInt(2), Warning: big.NewInt(1)}, Evidence: []api.SubmissionEvidence{{ID: actorID, Type: "artifact", Label: "source", Size: big.NewInt(9007199254740993)}}} + m := &Model{submission: &submission, tab: submissions, noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "My full summary", "Stored outcome: needs_revision", "Reason: coverage_incomplete", "Blocking findings: 2", "Version: 12", "Evidence label: source", "Size: 9007199254740993", "References", "Submission: "+submissionID) + page := api.SubmissionCheckerPage{Items: []api.SubmissionCheckerRun{{ID: runID, Submission: submissionID, Task: taskID, Status: "completed", Current: false, Results: []api.ContributorFinding{{ID: findingID, Severity: "high", Status: "failed", Checker: "lint", Message: &reason}}}}} + m.section, m.findings = 1, &page + rendered = m.detailText() + orderOf(t, rendered, "Historical checker run", "Run status: completed", "Run: "+runID, "Severity: high", "Finding status: failed", "Checker: lint", "Message\ncoverage_incomplete", "Finding: "+findingID, "stored submission outcome determines disposition") + page.Items[0].Results = nil + if !strings.Contains(m.detailText(), "does not establish a pass") { + t.Fatal(m.detailText()) + } +} diff --git a/cli/internal/tui/guide_findings_test.go b/cli/internal/tui/guide_findings_test.go index c6256194c..d58de66f4 100644 --- a/cli/internal/tui/guide_findings_test.go +++ b/cli/internal/tui/guide_findings_test.go @@ -46,12 +46,12 @@ func TestGuidePreservesOrderAndPublishesOnlyAfterConfirmation(t *testing.T) { }} m := openMineGuide(t, f) text := m.detailText() - for _, want := range []string{"Label: First", "Order: 2", doc1.SHA256, "Label: Second", "Order: 7", doc2.SHA256, "bytes not downloaded"} { + for _, want := range []string{"1. First", "2. Second", "Label: First", "Order: 2", doc1.DocumentID, doc1.SHA256, "bytes not downloaded"} { if !strings.Contains(text, want) { t.Fatal(want, text) } } - if strings.Index(text, "Label: First") > strings.Index(text, "Label: Second") { + if strings.Index(text, "1. First") > strings.Index(text, "2. Second") || strings.Contains(text, doc2.SHA256) { t.Fatal(text) } directory := filepath.Join(t.TempDir(), "qgr") @@ -59,6 +59,14 @@ func TestGuidePreservesOrderAndPublishesOnlyAfterConfirmation(t *testing.T) { t.Fatal(err) } press(t, m, "down") + for _, want := range []string{"Label: Second", "Order: 7", doc2.DocumentID, doc2.SHA256, "Bytes (server metadata; bytes not downloaded): 5"} { + if !strings.Contains(m.detailText(), want) { + t.Fatal("selected original lost exact metadata", want, m.detailText()) + } + } + if strings.Contains(m.detailText(), doc1.SHA256) { + t.Fatal("previous original metadata retained as selected") + } press(t, m, "d") press(t, m, "?") if m.guideDirectory != "?" { @@ -233,7 +241,7 @@ func TestFindingsNavigationPaginationAndMisbinding(t *testing.T) { press(t, m, "enter") press(t, m, "tab") text := m.detailText() - for _, want := range []string{"Run: " + runID, "Current: true", "Predecessor run: " + actorID, "Checker: lint", "Check ID: lint.safe", "Finding status: coverage_incomplete", "Severity: medium", "Path: answer.md", "Line: 8", "Suggested fix: repair", "Checker: format\nCheck ID: not supplied\nFinding status: passed\nSeverity: info\nPath: not supplied\nLine: not supplied\nMessage: not supplied\nSuggested fix: not supplied", "not acceptance"} { + for _, want := range []string{"Run: " + runID, "Current: true", "Predecessor run: " + actorID, "Checker: lint", "Check ID: lint.safe", "Finding status: coverage_incomplete", "Severity: medium", "Path: answer.md", "Line: 8", "Suggested fix\nrepair", "Severity: info\nFinding status: passed\nChecker: format\nCheck ID: not supplied\nMessage: not supplied\nPath: not supplied\nLine: not supplied\nSuggested fix: not supplied", "stored submission outcome determines disposition"} { if !strings.Contains(text, want) { t.Fatal(want, text) } @@ -258,7 +266,7 @@ func TestGuideAndFindingsViewsFitMinimumTerminal(t *testing.T) { }} guide := openMineGuide(t, guideClient) guide.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) - assertBoundedView(t, guide.View().Content, 40, 10, "GUIDE") + assertBoundedView(t, guide.View().Content, 40, 10, "[Guide]") findings := boot(t, &fakeClient{}) press(t, findings, "enter") @@ -267,7 +275,7 @@ func TestGuideAndFindingsViewsFitMinimumTerminal(t *testing.T) { press(t, findings, "enter") press(t, findings, "tab") findings.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) - assertBoundedView(t, findings.View().Content, 40, 10, "FINDINGS") + assertBoundedView(t, findings.View().Content, 40, 10, "[Findings]") } func assertBoundedView(t *testing.T, content string, width, height int, heading string) { diff --git a/cli/internal/tui/model.go b/cli/internal/tui/model.go index 106ee58f9..70ea7f8cc 100644 --- a/cli/internal/tui/model.go +++ b/cli/internal/tui/model.go @@ -42,6 +42,7 @@ type Model struct { cancel context.CancelFunc token string plain, noColor bool + backgroundKnown, darkBackground bool width, height int started, closed, blocked, picker, detail, loading bool help, handoff, filtering bool @@ -78,7 +79,12 @@ func New(ctx context.Context, client ReadClient, token string, plain, noColor bo noColor: noColor || plain, picker: true, page: 1, viewport: viewport.New()} } -func (m *Model) Init() tea.Cmd { return nil } +func (m *Model) Init() tea.Cmd { + if m.noColor { + return nil + } + return tea.RequestBackgroundColor +} // Close cancels outstanding reads after the program has stopped, including errors. func (m *Model) Close() { m.closed = true; m.invalidate(); m.stop() } @@ -95,6 +101,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.started = true return m, m.begin(profileRead) } + case tea.BackgroundColorMsg: + m.backgroundKnown, m.darkBackground = true, msg.IsDark() case response: return m, m.receive(msg) case tea.KeyPressMsg: @@ -190,6 +198,7 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { case "enter": if m.guideDirectory != "" { m.guideMode = guideConfirm + m.viewport.GotoTop() } case "esc": m.guideMode, m.guideDirectory = guideBrowse, "" @@ -211,9 +220,12 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { return nil } if k == "y" { + if m.tooSmall() { + return nil + } return m.beginGuideDownload() } - return nil + return m.scroll(k) } if k == "?" { m.help, m.handoff = !m.help, false @@ -314,8 +326,10 @@ func (m *Model) key(key tea.KeyPressMsg) tea.Cmd { switch k { case "up": m.guideSelected = max(0, m.guideSelected-1) + m.viewport.GotoTop() case "down": m.guideSelected = min(max(0, len(m.context.GuideDocuments)-1), m.guideSelected+1) + m.viewport.GotoTop() case "d": if len(m.context.GuideDocuments) > 0 { m.guideMode, m.guideDirectory, m.guideNotice = guideDestination, "", "" diff --git a/cli/internal/tui/render_evidence_test.go b/cli/internal/tui/render_evidence_test.go new file mode 100644 index 000000000..64633baf9 --- /dev/null +++ b/cli/internal/tui/render_evidence_test.go @@ -0,0 +1,147 @@ +package tui + +import ( + "encoding/json" + "fmt" + "image/color" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/Flow-Research/workstream/cli/internal/api" +) + +// These are presentation fixtures, not evidence of a live backend journey. +func renderFixture(t *testing.T, screen string) (*Model, *fakeClient) { + t.Helper() + f := &fakeClient{} + m := boot(t, f) + m.projectName = "Aster Research / fixture project" + m.observed = time.Date(2026, 10, 26, 14, 2, 18, 0, time.UTC) + if screen == "projects" { + m.rows[0].title = m.projectName + return m, f + } + press(t, m, "enter") + m.projectName = "Aster Research / fixture project" + m.rows[0].title = "Assess evidence for the coastal flood warning study" + if screen == "ready" { + return m, f + } + if screen == "empty" { + m.rows = nil + return m, f + } + if screen == "filter-miss" { + m.filter = "unmatched fixture" + return m, f + } + if screen == "denied" { + m.failed(&api.Failure{Status: 403, Code: "permission_denied"}, false) + return m, f + } + if screen == "help" { + m.help = true + return m, f + } + press(t, m, "tab") + press(t, m, "enter") + m.task.Title = "Assess the coastal flood warning study and document where its evidence supports the published conclusions" + m.task.Status = "in_progress" + m.task.Description = "Read the supplied field report and produce an evidence matrix linking each major conclusion to its source. Keep direct observations separate from assumptions.\n\nFor every conclusion, cite the section and page, explain the strength of the evidence, and note any material limitation. Preserve uncertainty: absence of evidence is not evidence of absence.\n\nDeliver evidence-matrix.csv and a concise methodology.md in one ZIP. Include enough context for another contributor to reproduce your assessment." + accept := "Every conclusion has a traceable citation or an explicit evidence gap. The matrix and methodology agree; no unsupported certainty is introduced." + reject := "Missing source references, invented evidence, or claims that cannot be traced to the supplied report." + m.task.AcceptanceCriteria, m.task.RejectionCriteria = &accept, &reject + m.task.SkillTags = []string{"evidence synthesis", "research"} + m.rows[0].title, m.rows[0].status = m.task.Title, "active" + m.task.Compensation.AcceptedSubmission = []byte(`[{"instrument":"money","unit":"USD","quantity":"40.000000000000000001"}]`) + if screen == "mine" { + return m, f + } + if screen == "intake" { + m.section = 2 + description := "One row per conclusion, with source section, page and evidence strength." + m.intake = &api.SubmissionRequirements{TaskID: taskID, ProjectID: projectID, GuideVersion: "research-guide", + RequiredPacketFields: []string{"summary", "attestation"}, + RequiredArtifacts: []api.ArtifactRequirement{{Key: "matrix", Path: "evidence-matrix.csv", Required: true, HashRequired: true, Description: &description}, {Key: "methodology", Path: "methodology.md", Required: true, HashRequired: true}}, + RequiredEvidence: []api.EvidenceRequirement{{Key: "sources", Label: "Source citation index", Required: true, HashRequired: true}}, + AttestationTerms: []string{"I have distinguished supplied evidence from my own interpretation."}, + MaximumPackageSizeBytes: big.NewInt(10485760), Packaging: api.PackagingRequirements{PackageRequired: true, AllowedPackageFormats: []string{"zip"}}} + return m, f + } + if screen == "guide-confirmation" { + m.section = 3 + context := guideContext(api.TaskGuideDocument{DocumentID: actorID, Label: "Research instructions", Order: 0, MediaType: "text/markdown", ByteCount: 12480, SHA256: "sha256:" + strings.Repeat("abcdef01", 8)}) + m.context, m.guideMode, m.guideDirectory = &context, guideConfirm, "/work/aster/locked-guide" + return m, f + } + m.tab, m.section = submissions, 0 + m.submission = &api.SubmissionHistory{ID: submissionID, Task: taskID, Version: big.NewInt(1), Status: "review_pending", Summary: "Evidence matrix and methodology for the coastal flood warning study", SubmittedAt: "2026-10-26T13:58:02Z", FindingCounts: api.SubmissionFindingCounts{Blocking: big.NewInt(0), Warning: big.NewInt(1), CoverageIncomplete: big.NewInt(0)}, Evidence: []api.SubmissionEvidence{{ID: findingID, Type: "document", Label: "Methodology and citation matrix", Size: big.NewInt(18520)}}} + if screen == "submission" { + return m, f + } + m.section, m.findingsPage = 1, 1 + message, fix, checkID, path := "The citation for conclusion 4 does not identify a page.", "Add the exact page reference or explain why the source is not paginated.", "citation-location", "evidence-matrix.csv" + line := int64(5) + m.findings = &api.SubmissionCheckerPage{Items: []api.SubmissionCheckerRun{{ID: runID, Task: taskID, Submission: submissionID, Status: "completed", Current: true, Version: big.NewInt(1), Attempt: big.NewInt(1), CreatedAt: "2026-10-26T13:58:05Z", Results: []api.ContributorFinding{{ID: findingID, Checker: "citation-checker", CheckID: &checkID, Status: "warning", Severity: "warning", Path: &path, Line: &line, Message: &message, Fix: &fix}}}}} + return m, f +} + +func TestRepresentativeRenderBoundsAndPureReflow(t *testing.T) { + for _, screen := range []string{"projects", "ready", "mine", "intake", "guide-confirmation", "submission", "findings", "empty", "filter-miss", "denied", "help"} { + for _, mode := range []string{"dark", "light", "no-color", "plain"} { + for _, size := range [][2]int{{120, 40}, {80, 24}, {40, 10}} { + name := fmt.Sprintf("%s-%s-%dx%d", screen, mode, size[0], size[1]) + t.Run(name, func(t *testing.T) { + m, f := renderFixture(t, screen) + m.plain, m.noColor = mode == "plain", mode == "plain" || mode == "no-color" + background := color.RGBA{R: 16, G: 25, B: 35, A: 255} + if mode == "light" { + background = color.RGBA{R: 255, G: 255, B: 255, A: 255} + } + m.Update(tea.BackgroundColorMsg{Color: background}) + before, selected, section, generation := len(f.calls), m.selectedResource(), m.section, m.generation + _, cmd := m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + if cmd != nil || len(f.calls) != before || m.selectedResource() != selected || m.section != section || m.generation != generation { + t.Fatal("presentation reflow changed request or exact navigation state") + } + content := m.View().Content + if lipgloss.Height(content) > size[1] { + t.Fatalf("height exceeds terminal: %d\n%s", lipgloss.Height(content), content) + } + for _, line := range strings.Split(content, "\n") { + if lipgloss.Width(line) > size[0] { + t.Fatalf("width exceeds terminal: %q", line) + } + } + if m.noColor && strings.Contains(content, "\x1b") { + t.Fatal("no-color output contains ANSI") + } + if !strings.Contains(content, "quit") { + t.Fatal("quit control is not visible") + } + if screen == "denied" && (strings.Contains(content, "Assess evidence") || strings.Contains(content, taskID)) { + t.Fatal("denial retained protected content") + } + if directory := os.Getenv("WORKSTREAM_TUI_RENDER_DIR"); directory != "" { + data, err := json.MarshalIndent(map[string]any{"kind": "fixture terminal render; not live backend proof", "screen": screen, "mode": mode, "columns": size[0], "rows": size[1], "content": content}, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, name+".json"), data, 0600); err != nil { + t.Fatal(err) + } + } + }) + } + } + } +} diff --git a/cli/internal/tui/shell_layout_test.go b/cli/internal/tui/shell_layout_test.go new file mode 100644 index 000000000..d3e434ec7 --- /dev/null +++ b/cli/internal/tui/shell_layout_test.go @@ -0,0 +1,158 @@ +package tui + +import ( + "fmt" + "image/color" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +func TestWideShellKeepsSelectionOutsideScrollingDetails(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + m.plain = false + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if got := m.View().Content; !strings.Contains(got, "Your projects") || !strings.Contains(got, "Project ID: "+projectID) || strings.Contains(got, "Exact instructions") { + t.Fatal("picker preview used more than loaded row facts", got) + } + press(t, m, "enter") + if got := m.View().Content; !strings.Contains(got, "[Ready] Mine Submissions") || !strings.Contains(got, "Enter to inspect full details") || strings.Contains(got, "Exact instructions") { + t.Fatal("ready preview hid navigation or leaked unloaded detail", got) + } + press(t, m, "tab") + press(t, m, "enter") + m.task.Description = strings.Repeat("Detailed instructions for the selected assignment. ", 150) + m.reflow() + before := len(f.calls) + m.viewport.ScrollDown(30) + content := m.View().Content + if !strings.Contains(content, "[Mine]") || !strings.Contains(content, "[Overview] Context Intake Guide") || !strings.Contains(content, "> Assigned task [active]") { + t.Fatal("scroll detached selected work or visible navigation", content) + } + if m.viewport.YOffset() == 0 || len(f.calls) != before { + t.Fatal("detail did not scroll independently of requests") + } + for _, line := range strings.Split(content, "\n") { + if lipgloss.Width(line) > 120 { + t.Fatalf("wide line exceeds 120 cells: %q", line) + } + } +} + +func TestWideLongListKeepsSelectionVisibleAndReversible(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + m.plain = false + press(t, m, "enter") + m.rows = make([]row, 75) + for i := range m.rows { + m.rows[i] = row{id: taskID, task: taskID, project: projectID, title: fmt.Sprintf("Entry %03d", i), status: "ready"} + } + m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + before := len(f.calls) + for range 70 { + press(t, m, "down") + } + if m.selected != 70 || !strings.Contains(m.View().Content, "> Entry 070 [ready]") || len(f.calls) != before { + t.Fatal("long list lost selection or dispatched a read") + } + press(t, m, "up") + if m.selected != 69 || !strings.Contains(m.View().Content, "> Entry 069 [ready]") { + t.Fatal("selection did not reverse immediately") + } + press(t, m, "enter") + if len(f.calls) != before+1 || !strings.Contains(m.View().Content, "> Entry 069 [ready]") { + t.Fatal("fresh detail read detached selected row") + } + m.task.Description = strings.Repeat("long detail paragraph ", 150) + m.reflow() + m.viewport.ScrollDown(20) + if !strings.Contains(m.View().Content, "> Entry 069 [ready]") || m.viewport.YOffset() == 0 { + t.Fatal("scrolling detail moved or hid the worklist selection") + } +} + +func TestCompactShellReflowPreservesStateAndUsesOneColumn(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + m.plain = false + press(t, m, "enter") + press(t, m, "tab") + press(t, m, "enter") + selected, page, generation := m.selected, m.page, m.generation + before := len(f.calls) + for _, size := range [][2]int{{80, 24}, {40, 10}, {120, 40}, {80, 24}} { + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + content := m.View().Content + if lipgloss.Height(content) > size[1] { + t.Fatalf("%dx%d view exceeded rows: %s", size[0], size[1], content) + } + for _, line := range strings.Split(content, "\n") { + if lipgloss.Width(line) > size[0] { + t.Fatalf("%dx%d view exceeded columns: %q", size[0], size[1], line) + } + } + if size[0] < 100 && strings.Contains(content, "> Assigned task [active]") { + t.Fatal("compact detail unexpectedly retained side-by-side worklist", content) + } + } + if m.selected != selected || m.page != page || m.generation != generation || len(f.calls) != before || m.task == nil { + t.Fatal("resize changed selection, request generation, or detail") + } +} + +func TestNoColorAndUnknownBackgroundUsePlainDefaults(t *testing.T) { + m := boot(t, &fakeClient{}) + m.plain = false + m.noColor = true + if strings.Contains(m.View().Content, "\x1b") { + t.Fatal("no-color shell emitted ANSI") + } + m.noColor = false + m.backgroundKnown = false + if strings.Contains(m.View().Content, "\x1b") { + t.Fatal("unknown terminal background emitted assumed dark colors") + } +} + +func TestDetectedLightBackgroundUsesReadablePaletteWithoutCanvas(t *testing.T) { + m := boot(t, &fakeClient{}) + m.plain, m.noColor = false, false + m.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 8, G: 14, B: 20, A: 255}}) + dark := m.styles() + m.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 250, G: 250, B: 246, A: 255}}) + light := m.styles() + if !m.backgroundKnown || m.darkBackground || reflect.DeepEqual(dark.heading.GetForeground(), light.heading.GetForeground()) || light.selected.GetBackground() == nil { + t.Fatal("light terminal did not switch semantic colors and selection surface") + } + if m.View().BackgroundColor != nil { + t.Fatal("presentation forced a canvas over the terminal's own background") + } +} + +func TestFilterEditingFooterMatchesEffectiveKeys(t *testing.T) { + f := &fakeClient{} + m := boot(t, f) + press(t, m, "enter") + m.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) + before := len(f.calls) + press(t, m, "/") + press(t, m, "q") + if m.closed || m.filter != "q" || !strings.Contains(m.View().Content, "Enter apply Esc cancel Ctrl+C quit") || strings.Contains(m.View().Content, "q quit") { + t.Fatal("filter mode advertised or handled q as quit", m.View().Content) + } + press(t, m, "enter") + if m.filtering || m.filter != "q" || len(f.calls) != before { + t.Fatal("Enter did not apply locally without a request") + } + press(t, m, "/") + press(t, m, "x") + press(t, m, "esc") + if m.filtering || m.filter != "q" || len(f.calls) != before { + t.Fatal("Esc did not restore the previous local filter") + } +} diff --git a/cli/internal/tui/styles.go b/cli/internal/tui/styles.go new file mode 100644 index 000000000..bc15fd02d --- /dev/null +++ b/cli/internal/tui/styles.go @@ -0,0 +1,30 @@ +package tui + +import "charm.land/lipgloss/v2" + +// styleSet carries semantic emphasis without controlling the terminal canvas. +type styleSet struct { + heading, secondary, focus, action, caution, error, selected, divider lipgloss.Style +} + +func (m *Model) styles() styleSet { + plain := lipgloss.NewStyle() + if m.noColor || !m.backgroundKnown { + return styleSet{plain, plain, plain, plain, plain, plain, plain, plain} + } + color := func(value string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(value)) } + if !m.darkBackground { + return styleSet{ + heading: color("#087E8C").Bold(true), secondary: color("#465B62"), + focus: color("#087E8C").Background(lipgloss.Color("#E7EFF0")).Bold(true), action: color("#95482E"), + caution: color("#7D5A17"), error: color("#A63345"), + selected: color("#244D58").Background(lipgloss.Color("#E7EFF0")), divider: color("#9AACB0"), + } + } + return styleSet{ + heading: color("#69D7E3").Bold(true), secondary: color("#A9BAC3"), + focus: color("#69D7E3").Background(lipgloss.Color("#192632")).Bold(true), action: color("#F2AE91"), + caution: color("#E9C37B"), error: color("#ED8991"), + selected: color("#EAF2F4").Background(lipgloss.Color("#192632")), divider: color("#354955"), + } +} diff --git a/cli/internal/tui/text.go b/cli/internal/tui/text.go index 3c5405b97..7b9cb1681 100644 --- a/cli/internal/tui/text.go +++ b/cli/internal/tui/text.go @@ -12,6 +12,10 @@ func (m *Model) clean(value string) string { if m.token != "" { value = strings.ReplaceAll(value, m.token, "[redacted]") } + return escapeControls(value) +} + +func escapeControls(value string) string { var out strings.Builder for _, r := range value { if !unicode.IsPrint(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' { @@ -22,3 +26,18 @@ func (m *Model) clean(value string) string { } return out.String() } + +// prose keeps author-supplied paragraph boundaries without treating any other +// external control character as terminal instructions. Redact before splitting +// so a bearer containing a line break cannot escape suppression. +func (m *Model) prose(value string) string { + if m.token != "" { + value = strings.ReplaceAll(value, m.token, "[redacted]") + } + value = strings.ReplaceAll(value, "\r\n", "\n") + parts := strings.Split(value, "\n") + for i := range parts { + parts[i] = escapeControls(parts[i]) + } + return strings.Join(parts, "\n") +} diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go index 724e253b9..301c5833c 100644 --- a/cli/internal/tui/view.go +++ b/cli/internal/tui/view.go @@ -2,65 +2,175 @@ package tui import ( "fmt" - "math/big" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/Flow-Research/workstream/cli/internal/api" ) -func (m *Model) heading() string { - if m.picker { - return "PROJECTS" +// fitText is for display labels, never exact identities or selectors. +func fitText(value string, width int) string { + if width < 1 { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + if width <= 3 { + return strings.Repeat(".", width) + } + first, _, _ := strings.Cut(lipgloss.Wrap(value, width-3, ""), "\n") + return first + "..." +} + +func (m *Model) headerText() string { + header := "WORKSTREAM / Contribute" + if m.projectName != "" { + header += " / " + m.clean(m.projectName) } - if !m.detail { - return m.tab.String() + return fitText(header, m.width) +} + +func (m *Model) collectionText() string { + if m.picker { + return "Your projects" } - if m.tab == submissions { - if m.section == 1 { - return "FINDINGS" + labels := [...]string{"Ready", "Mine", "Submissions"} + parts := make([]string, 0, len(labels)) + for i, label := range labels { + if tab(i) == m.tab { + label = "[" + label + "]" } - return "SUBMISSION" + parts = append(parts, label) } - return [...]string{"TASK", "CONTEXT", "INTAKE", "GUIDE"}[m.section] + return strings.Join(parts, " ") } -// fitText bounds a display label, never an exact identity or request selector. -// Callers pass text that has already crossed clean. -func fitText(value string, width int) string { - if lipgloss.Width(value) <= width { - return value +func (m *Model) sectionText() string { + if !m.detail || m.help || m.handoff || m.failure != "" || m.guideMode != guideBrowse { + return "" + } + var labels []string + switch m.tab { + case mine: + labels = []string{"Overview", "Context", "Intake", "Guide"} + case submissions: + labels = []string{"Overview", "Findings"} + default: + labels = []string{"Overview"} + } + for i := range labels { + if i == m.section { + labels[i] = "[" + labels[i] + "]" + } } - first, _, _ := strings.Cut(lipgloss.Wrap(value, max(1, width-3), ""), "\n") - return first + "..." + return strings.Join(labels, " ") } -func (m *Model) headerText() string { - header := "WORKSTREAM / CONTRIBUTE / " + m.heading() - if m.project != "" { - header += "\nProject: " + fitText(m.clean(m.projectName), m.width-9) + "\nID: " + m.clean(m.project) +func (m *Model) renderLabels(value string) string { + styles := m.styles() + parts := strings.Split(value, " ") + for i, part := range parts { + if strings.HasPrefix(part, "[") { + parts[i] = styles.focus.Render(part) + } else { + parts[i] = styles.secondary.Render(part) + } } - return header + return strings.Join(parts, " ") } func (m *Model) footerText() string { if m.guideMode == guideDestination { - return "enter confirm | esc cancel | ctrl+c quit" + return "Enter review Esc cancel Ctrl+C quit" } if m.guideMode == guideConfirm { - return "y download | esc cancel | q quit" + if m.width < 60 { + return "y download up/down Esc cancel q quit" + } + return "y download arrows/PgUp/PgDn inspect Esc cancel q quit" + } + if m.filtering { + return "Enter apply Esc cancel Ctrl+C quit" + } + if m.help { + if m.width < 60 { + return "up/down ?/Esc close q quit" + } + return "arrows/PgUp/PgDn scroll ?/Esc close q quit" + } + if m.handoff { + if m.width < 60 { + return "up/down c/Esc close q quit" + } + return "arrows/PgUp/PgDn scroll c/Esc close q quit" + } + if m.blocked { + return "? help q quit" + } + if m.failure != "" { + return "r refresh g projects ? help q quit" + } + if m.loading { + return "Loading... r refresh ? help q quit" } if m.height < 16 { - return "? keys | q quit" + if m.detail { + return "scroll Esc back ? help q quit" + } + if m.picker { + return "Enter project ? help q quit" + } + return "Enter inspect / filter q quit" + } + if m.width >= 110 { + switch { + case m.detail && m.tab == mine && m.section == 3 && m.context != nil && len(m.context.GuideDocuments) > 0: + return "arrows select/scroll d download Tab section Esc back r refresh g projects c CLI ? help q quit" + case m.detail && m.tab == submissions && m.section == 1 && m.findingsCursor != "": + return "arrows scroll n next Tab section Esc back r refresh g projects c CLI ? help q quit" + case m.detail && m.tab != ready: + return "arrows scroll Tab section Esc back r refresh g projects c CLI ? help q quit" + case m.detail: + return "arrows scroll Esc back r refresh g projects c CLI ? help q quit" + case m.picker && m.cursor != nil: + return "arrows select Enter project / filter n next r refresh c CLI ? help q quit" + case m.picker: + return "arrows select Enter project / filter r refresh c CLI ? help q quit" + case m.cursor != nil: + return "arrows select Enter inspect Tab collection n next / filter r refresh g projects ? help q quit" + default: + return "arrows select Enter inspect Tab collection / filter r refresh g projects ? help q quit" + } } if m.detail && m.tab == mine && m.section == 3 { - return "arrows select | d download | tab section | esc back\nr refresh | c CLI | ? help | q quit" + if m.context == nil || len(m.context.GuideDocuments) == 0 { + return "arrows scroll Tab section Esc back\nr refresh g projects c CLI ? help q quit" + } + return "arrows select/scroll d download Tab section Esc back\nr refresh g project c CLI ? help q quit" } if m.detail && m.tab == submissions && m.section == 1 { - return "arrows scroll | n next | tab overview | esc back\nr refresh | c CLI | ? help | q quit" + if m.findingsCursor == "" { + return "arrows/PgUp/PgDn scroll Tab section Esc back\nr refresh g projects c CLI ? help q quit" + } + return "arrows/PgUp/PgDn scroll n next Tab section Esc back\nr refresh g project c CLI ? help q quit" + } + if m.detail { + if m.tab == ready { + return "arrows/PgUp/PgDn scroll Esc back\nr refresh g projects c CLI ? help q quit" + } + return "arrows/PgUp/PgDn scroll Tab section Esc back\nr refresh g project c CLI ? help q quit" + } + if m.picker { + if m.cursor == nil { + return "arrows select Enter project / filter\nr refresh c CLI ? help q quit" + } + return "arrows select Enter project / filter n next\nr refresh c CLI ? help q quit" } - return "arrows select/scroll | enter inspect | tab section | esc back\ng project | n next | r refresh | / local filter | c CLI | ? help | q quit" + if m.cursor == nil { + return "arrows select Enter inspect Tab collection / filter\nr refresh g projects c CLI ? help q quit" + } + return "arrows select Enter inspect Tab collection n next / filter\nr refresh g project c CLI ? help q quit" } func (m *Model) statusText() string { @@ -69,37 +179,41 @@ func (m *Model) statusText() string { if m.findings != nil { count = len(m.findings.Items) } - status := fmt.Sprintf("page %d | fetched %d runs", m.findingsPage, count) + status := fmt.Sprintf("findings page %d | loaded %d runs", m.findingsPage, count) if m.findingsCursor != "" { status += " | more available" } if !m.observed.IsZero() { - status += " observed " + m.observed.Format("15:04:05") + status += " | observed " + m.observed.Format("15:04:05") } return fitText(status, m.width) } - status := fmt.Sprintf("page %d | fetched %d", m.page, len(m.rows)) - if m.width < 70 { - status = fmt.Sprintf("page %d rows %d", m.page, len(m.rows)) - } + status := fmt.Sprintf("page %d | loaded %d", m.page, len(m.rows)) if m.cursor != nil { - if m.width < 70 { - status += " more" - } else { - status += " | more available" - } + status += " | more available" } if !m.observed.IsZero() { - status += " observed " + m.observed.Format("15:04:05") + status += " | observed " + m.observed.Format("15:04:05") } if m.filtering { return fitText("Filter fetched page only: "+m.clean(m.filter), m.width) + "\n" + fitText(status, m.width) - } else if m.filter != "" { + } + if m.filter != "" { return fitText("local filter: "+m.clean(m.filter), m.width) + "\n" + fitText(status, m.width) } return fitText(status, m.width) } +func (m *Model) widePanes() bool { + return m.width >= 100 && m.height >= 24 && !m.plain +} + +func (m *Model) leftWidth() int { return min(36, max(30, m.width/3)) } + +func padCells(value string, width int) string { + return value + strings.Repeat(" ", max(0, width-lipgloss.Width(value))) +} + func (m *Model) View() tea.View { if m.closed { return tea.NewView("") @@ -109,34 +223,67 @@ func (m *Model) View() tea.View { v.AltScreen = !m.plain return v } - header := m.headerText() - if !m.noColor { - header = lipgloss.NewStyle().Foreground(lipgloss.Color("#69D7E3")).Render(header) + styles := m.styles() + lines := []string{styles.heading.Render(m.headerText()), m.renderLabels(m.collectionText())} + if section := m.sectionText(); section != "" { + lines = append(lines, m.renderLabels(section)) + } + lines = append(lines, styles.divider.Render(m.dividerText())) + if m.showPanes() { + leftWidth := m.leftWidth() + left := m.listLines(leftWidth, m.viewport.Height()) + right := strings.Split(m.viewport.View(), "\n") + for i := 0; i < m.viewport.Height(); i++ { + var l, r string + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + lines = append(lines, padCells(l, leftWidth)+" "+styles.divider.Render("|")+" "+r) + } + } else { + lines = append(lines, m.viewport.View()) } - footer, status := m.footerText(), m.statusText() - content := lipgloss.Wrap(header, m.width, "") + "\n" + m.viewport.View() + "\n" + - lipgloss.Wrap(status, m.width, "") + "\n" + lipgloss.Wrap(footer, m.width, "") - v := tea.NewView(content) + lines = append(lines, styles.secondary.Render(m.statusText()), styles.secondary.Render(m.footerText())) + v := tea.NewView(strings.Join(lines, "\n")) v.AltScreen = !m.plain return v } +func (m *Model) showPanes() bool { + return m.widePanes() && m.guideMode == guideBrowse && !m.help && !m.handoff && m.failure == "" && !m.loading && m.started +} + +func (m *Model) dividerText() string { + unit := "─" + if m.plain { + unit = "-" + } + return strings.Repeat(unit, m.width) +} + func (m *Model) reflow() { if m.width < 1 || m.height < 1 { return } - // Reserve wrapped status/help lines; never measure unescaped server text. - reserved := lipgloss.Height(lipgloss.Wrap(m.headerText(), m.width, "")) + - lipgloss.Height(lipgloss.Wrap(m.footerText(), m.width, "")) + 1 + lipgloss.Height(m.statusText()) - m.viewport.SetWidth(m.width) + reserved := 1 + 1 + 1 + lipgloss.Height(m.statusText()) + lipgloss.Height(m.footerText()) + if m.sectionText() != "" { + reserved++ + } + width := m.width + if m.showPanes() { + width -= m.leftWidth() + 3 + } + m.viewport.SetWidth(width) m.viewport.SetHeight(max(1, m.height-reserved)) - body := m.body() - m.viewport.SetContent(lipgloss.Wrap(body, m.width, "")) + m.viewport.SetContent(lipgloss.Wrap(m.body(), width, "")) } func (m *Model) body() string { if m.help { - return "KEYBOARD HELP\nArrows select a row/document or scroll detail. Enter inspects one exact resource.\nTab: Ready/Mine/Submissions in lists; Overview/Context/Intake/Guide in active Mine detail; Overview/Findings in Submission detail.\nEsc returns or cancels destination/confirmation without a download. g fetches a fresh project picker.\nn fetches exactly one next findings/list page. r resets the current read; no polling.\n/ filters the fetched list page only; enter finishes, esc cancels.\nd enters a destination for one selected guide original; y confirms.\nc shows inert CLI arguments. q or Ctrl+C quits.\nChecker evidence is not acceptance. A read is not a reservation or authority.\n? or Esc closes help." + return "KEYBOARD HELP\n\nNavigate\nArrows select a row/document or scroll detail. Tab changes collections or visible detail sections. Esc returns.\n\nInspect\nEnter reads one exact selected resource. r explicitly refreshes; g opens a fresh project picker. / filters only the loaded page.\n\nPages\nn requests exactly one next list or findings page. No polling or project-wide count is implied.\n\nGuide downloads\nd enters a destination for the selected guide original; Enter reviews and y confirms. Esc cancels without downloading.\n\nExit\nc shows inert CLI arguments. q or Ctrl+C quits. ? or Esc closes help.\n\nChecker evidence is not acceptance. A read is not a reservation or authority." } if m.handoff { return m.handoffText() @@ -145,28 +292,42 @@ func (m *Model) body() string { return m.failure + "\nNo retained page or detail is available.\n" + m.failureHelp() } if m.loading { - return "Loading..." + return m.loadingText() } if !m.started { return "Waiting for terminal size..." } if m.detail { - if m.width >= 100 && !m.plain { - leftWidth := m.width / 3 - return lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(leftWidth).Render(m.listText(leftWidth)), " | ", - lipgloss.NewStyle().Width(m.width-leftWidth-3).Render(lipgloss.Wrap(m.detailText(), m.width-leftWidth-3, ""))) - } return m.detailText() } - list := m.listText(m.width) - if m.width >= 100 && !m.plain && !m.picker { - leftWidth := m.width/2 - 2 - return lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(leftWidth).Render(m.listText(leftWidth)), - " | ", lipgloss.NewStyle().Width(m.width-leftWidth-3).Render("SELECTED TASK\nOpen to inspect.\nA summary is not full instructions or a reservation.")) + if m.widePanes() { + return m.previewText() } - return list + return m.listText(m.width) +} + +func (m *Model) loadingText() string { + switch m.request.kind { + case profileRead: + return "Loading actor profile..." + case projectsRead: + return "Loading projects..." + case pageRead: + return "Loading " + strings.ToLower(m.tab.String()) + " page..." + case taskRead: + return "Loading task overview..." + case contextRead: + return "Loading locked context and guide..." + case intakeRead: + return "Loading intake requirements..." + case submissionRead: + return "Loading submission overview..." + case findingsRead: + return "Loading checker findings..." + case guideDownloadRead: + return "Downloading selected guide original..." + } + return "Loading..." } func (m *Model) failureHelp() string { @@ -176,265 +337,50 @@ func (m *Model) failureHelp() string { return "r explicit refresh | g fresh project picker | ? help | q quit" } -func (m *Model) listText(width int) string { - rows := m.visible() - if len(rows) == 0 { - return "No rows on this fetched page.\nThis is not a project-wide count." - } - var out strings.Builder - // Keep the selected row in the list viewport without retaining other pages. - first := max(0, m.selected-max(1, m.viewport.Height()-3)) - for i := first; i < len(rows); i++ { - mark := " " - if i == m.selected { - mark = "> " - } - status := m.clean(rows[i].status) - available := max(1, width-lipgloss.Width(status)-5) - title := m.clean(rows[i].title) - title = fitText(title, available) - fmt.Fprintf(&out, "%s%s [%s]\n", mark, title, status) +func (m *Model) previewText() string { + selected, ok := m.selectedRow() + if !ok { + return "Select a row to preview.\nEnter to inspect full details." } - return out.String() + return "Selected on this page\n" + m.clean(selected.title) + "\nStatus: " + m.clean(selected.status) + "\nProject ID: " + m.clean(selected.project) + "\n\nEnter to inspect full details.\nA list summary is not full instructions or a reservation." } -func number(v *big.Int) string { - if v == nil { - return "not supplied" - } - return v.String() +func (m *Model) listText(width int) string { + return strings.Join(m.listLines(width, max(1, m.viewport.Height())), "\n") } -func (m *Model) detailText() string { - var out strings.Builder - line := func(label, value string) { out.WriteString(label + ": " + m.clean(value) + "\n") } - optional := func(label string, value *string) { - if value != nil { - line(label, *value) +func (m *Model) listLines(width, height int) []string { + rows := m.visible() + if len(rows) == 0 { + if m.filter != "" && len(m.rows) > 0 { + return []string{"No matches on this fetched page.", "Clear / to see loaded rows."} } + return []string{"No rows on this fetched page.", "This is not a project-wide count."} } - if m.tab == submissions && m.section == 1 && m.findings != nil { - return m.findingsText() - } - if m.submission != nil { - v := m.submission - line("Submission", v.ID) - line("Task", v.Task) - line("Stored outcome", v.Status) - optional("Reason", v.StatusReason) - line("Version", number(v.Version)) - line("Summary", v.Summary) - line("Submitted", v.SubmittedAt) - optional("Decided", v.DecidedAt) - line("Blocking findings", number(v.FindingCounts.Blocking)) - line("Warning findings", number(v.FindingCounts.Warning)) - line("Coverage incomplete", number(v.FindingCounts.CoverageIncomplete)) - optional("Predecessor", v.Predecessor) - for _, e := range v.Evidence { - line("Evidence label", e.Label) - line("Evidence ID", e.ID) - line("Type", e.Type) - line("Size", number(e.Size)) - } - out.WriteString("Tab opens contributor-visible checker findings.\nStored observations do not predict acceptance or fulfillment.") - return out.String() - } - if m.section == 3 && m.context != nil { - return m.guideText() - } - if m.section == 1 && m.context != nil { - v := m.context - line("Task", v.Task.TaskID) - line("Project", v.Project.ID) - line("Guide", v.Guide.ID) - line("Locked guide version", v.Guide.Version) - line("Review policy", v.ReviewPolicy.PolicyID) - line("Review policy generation", fmt.Sprint(v.ReviewPolicy.Generation)) - line("Review policy hash", v.ReviewPolicy.PolicyHash) - line("Revision policy", v.RevisionPolicy.PolicyID) - line("Revision policy generation", fmt.Sprint(v.RevisionPolicy.Generation)) - line("Revision policy hash", v.RevisionPolicy.PolicyHash) - line("Contribution policy version", v.ContributionPolicyVersionID) - line("Assigned to current actor", fmt.Sprint(v.Lifecycle.AssignedToCurrentActor)) - line("Observed action hints (not authority)", strings.Join(v.Lifecycle.NextActions, ", ")) - for _, d := range v.GuideDocuments { - line("Document label", d.Label) - line("Document ID", d.DocumentID) - line("Media type", d.MediaType) - line("Bytes", fmt.Sprint(d.ByteCount)) - line("SHA-256 (server metadata; bytes not downloaded)", d.SHA256) - } - if v.RevisionContext != nil { - r := v.RevisionContext - line("Revision preparation", r.PreparationID) - line("Revision preparation digest", r.PreparationSHA256) - line("Revision source Submission", r.SourceSubmissionID) - line("Revision disposition", r.Disposition) - line("Revision clock", r.ClockState) - optional("Revision deadline", r.DeadlineAt) - } - return out.String() - } - if m.section == 2 && m.intake != nil { - v := m.intake - line("Task", v.TaskID) - line("Project", v.ProjectID) - line("Locked guide version", v.GuideVersion) - line("Required packet fields", strings.Join(v.RequiredPacketFields, ", ")) - for _, a := range v.RequiredArtifacts { - line("Artifact", a.Key) - line("Path (text only)", a.Path) - line("Required", fmt.Sprint(a.Required)) - line("Hash required", fmt.Sprint(a.HashRequired)) - optional("Description", a.Description) - } - for _, e := range v.RequiredEvidence { - line("Evidence", e.Key) - line("Label", e.Label) - line("Required", fmt.Sprint(e.Required)) - line("Hash required", fmt.Sprint(e.HashRequired)) - optional("Description", e.Description) - } - for _, a := range v.ForbiddenArtifacts { - line("Forbidden pattern", a.Pattern) - optional("Reason", a.Reason) - optional("Suggested fix", a.WorkerFacingFix) - optional("Severity", a.Severity) - } - for _, term := range v.AttestationTerms { - line("Attestation term", term) - } - line("Manifest required", fmt.Sprint(v.ManifestRequired)) - line("Artifact hash required", fmt.Sprint(v.ArtifactHashRequired)) - line("Hash algorithm", v.ArtifactHashAlgorithm) - line("Allowed storage schemes", strings.Join(v.AllowedStorageSchemes, ", ")) - line("Allowed URI prefixes (text only)", strings.Join(v.StorageReferenceRules.AllowedURIPrefixes, ", ")) - line("Storage credentials allowed", fmt.Sprint(v.StorageReferenceRules.CredentialsAllowed)) - line("Storage query strings allowed", fmt.Sprint(v.StorageReferenceRules.QueryStringsAllowed)) - line("Storage fragments allowed", fmt.Sprint(v.StorageReferenceRules.FragmentsAllowed)) - line("Storage path traversal allowed", fmt.Sprint(v.StorageReferenceRules.PathTraversalAllowed)) - line("Maximum file bytes", number(v.MaximumFileSizeBytes)) - line("Maximum package bytes", number(v.MaximumPackageSizeBytes)) - line("Maximum archive bytes", number(v.MaximumArchiveSizeBytes)) - line("Maximum archive entries", number(v.MaximumArchiveEntries)) - line("Package required", fmt.Sprint(v.Packaging.PackageRequired)) - line("Allowed package formats", strings.Join(v.Packaging.AllowedPackageFormats, ", ")) - out.WriteString("Intake requirements are not a preparation or Submission.\nNo package is uploaded.") - return out.String() - } - if m.task != nil { - v := m.task - line("Task", v.TaskID) - line("Title", v.Title) - line("Status", v.Status) - line("Instructions", v.Description) - optional("Acceptance criteria", v.AcceptanceCriteria) - optional("Rejection criteria", v.RejectionCriteria) - line("Skills", strings.Join(v.SkillTags, ", ")) - line("Contribution policy version", v.Compensation.ContributionPolicyVersionID) - for _, terms := range []struct { - label string - raw []byte - }{{"Accepted submission terms", v.Compensation.AcceptedSubmission}, {"Completed review terms", v.Compensation.CompletedReview}} { - awards, ok := api.CompensationAwards(terms.raw) - if !ok { - line(terms.label, "unavailable") - continue - } - if len(awards) == 0 { - line(terms.label, "unpaid") - } - for _, a := range awards { - line(terms.label, a.Instrument+" / "+a.Unit+" / "+a.Quantity) - } - } - out.WriteString("Terms are potential earned terms, not acceptance or fulfillment.\nNo action can be sent from this workbench.") + first := max(0, m.selected-height+1) + if first > len(rows)-height { + first = max(0, len(rows)-height) } - return out.String() -} - -func (m *Model) guideText() string { - var out strings.Builder - v := m.context - line := func(label, value string) { out.WriteString(label + ": " + m.clean(value) + "\n") } - line("Task", v.Task.TaskID) - line("Guide", v.Guide.ID) - line("Locked guide version", v.Guide.Version) - if len(v.GuideDocuments) == 0 { - out.WriteString("No assigned guide documents.\n") - } - for index, d := range v.GuideDocuments { + end := min(len(rows), first+height) + lines := make([]string, 0, end-first) + for i := first; i < end; i++ { mark := " " - if index == m.guideSelected { + if i == m.selected { mark = "> " } - out.WriteString(mark + "Document " + fmt.Sprint(index+1) + "\n") - line("Label", d.Label) - line("Document ID", d.DocumentID) - line("Order", fmt.Sprint(d.Order)) - line("Media type", d.MediaType) - line("Bytes (server metadata; bytes not downloaded)", fmt.Sprint(d.ByteCount)) - line("SHA-256 (server metadata; bytes not downloaded)", d.SHA256) - } - if m.guideMode == guideDestination { - line("Download directory", m.guideDirectory) - out.WriteString("Enter confirms this existing directory; no directory is created.\n") - } else if m.guideMode == guideConfirm && len(v.GuideDocuments) > 0 { - d := v.GuideDocuments[m.guideSelected] - out.WriteString("Confirm download\n") - line("Task", v.Task.TaskID) - line("Document", d.DocumentID) - line("Download directory", m.guideDirectory) - line("Filename", d.Filename()) - out.WriteString("y downloads verified bytes | esc cancels\n") - } else if m.guideNotice != "" { - out.WriteString(m.guideNotice + "\n") - } - out.WriteString("Guide metadata is not verified downloaded bytes.") - return out.String() -} - -func (m *Model) findingsText() string { - var out strings.Builder - line := func(label, value string) { out.WriteString(label + ": " + m.clean(value) + "\n") } - optional := func(label string, value *string) { - if value == nil { - line(label, "not supplied") - } else { - line(label, *value) - } - } - if len(m.findings.Items) == 0 { - out.WriteString("No checker runs on this fetched page.\n") - } - for _, run := range m.findings.Items { - line("Run", run.ID) - line("Submission", run.Submission) - line("Task", run.Task) - line("Submission version", number(run.Version)) - line("Run status", run.Status) - line("Attempt", number(run.Attempt)) - line("Current", fmt.Sprint(run.Current)) - optional("Predecessor run", run.Predecessor) - line("Created", run.CreatedAt) - optional("Completed", run.CompletedAt) - for _, finding := range run.Results { - line("Finding", finding.ID) - line("Checker", finding.Checker) - optional("Check ID", finding.CheckID) - line("Finding status", finding.Status) - line("Severity", finding.Severity) - optional("Path", finding.Path) - if finding.Line == nil { - line("Line", "not supplied") - } else { - line("Line", fmt.Sprint(*finding.Line)) + status := fitText(m.clean(rows[i].status), max(1, width/3)) + available := max(1, width-lipgloss.Width(status)-5) + title := fitText(m.clean(rows[i].title), available) + line := mark + title + " [" + status + "]" + if i == m.selected { + styles := m.styles() + marker := styles.focus + if m.detail { + marker = styles.secondary } - optional("Message", finding.Message) - optional("Suggested fix", finding.Fix) + line = marker.Render(">") + styles.selected.Render(padCells(" "+title+" ["+status+"]", width-1)) } - out.WriteString("\n") + lines = append(lines, line) } - out.WriteString("Checker findings are evidence, not acceptance.") - return out.String() + return lines } diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index c6d39f12e..5a48276ce 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -138,13 +138,13 @@ def open_guide(terminal): terminal.send("\r") terminal.wait(SECRET) terminal.send("\t") - terminal.wait("CONTEXT") + terminal.wait("[Context]") terminal.wait("Locked guide version: guide-1") terminal.send("\t") - terminal.wait("INTAKE") + terminal.wait("[Intake]") terminal.wait("answer.md") terminal.send("\t") - terminal.wait("GUIDE") + terminal.wait("[Guide]") terminal.wait("Locked instructions") @@ -180,6 +180,78 @@ def open_findings(terminal): terminal.send("\t") +def reveal_detail(terminal, text): + """Inspect a long detail through real keyboard scrolling, not raw history.""" + for _ in range(100): + terminal.pump(0.03) + if text in terminal.screen: + return + terminal.send("\x1b[B") + assert text in terminal.screen, (text, terminal.screen) + + +@pytest.mark.parametrize("light", [False, True]) +def test_minimum_terminal_navigation_and_light_background(light): + reads = Reads() + with ( + socket_peer(reads) as (origin, requests), + Terminal( + origin, TOKEN, columns=40, rows=10, extra_env={"COLORTERM": "truecolor"} + ) as terminal, + ): + terminal.light_background = light + open_ready(terminal) + terminal.send("\r") + terminal.wait("[Overview]") + reveal_detail(terminal, SECRET) + assert "[Ready]" in terminal.screen + assert "quit" in terminal.screen + calls = len(requests) + terminal.resize(120, 40) + terminal.pump(0.2) + terminal.resize(40, 10) + terminal.pump(0.2) + assert len(requests) == calls, "resize dispatched a read" + terminal.send("\x1b") + terminal.wait("Ready terminal task") + expected_focus = b"38;2;8;126;140" if light else b"38;2;105;215;227" + assert expected_focus in terminal.raw, ( + "terminal background did not select its readable palette" + ) + terminal.quit() + assert TOKEN.encode() not in terminal.raw + + +def test_hidden_native_confirmation_does_not_download(tmp_path): + reads = guide_reads() + with ( + socket_peer(reads) as (origin, requests), + Terminal(origin, TOKEN, "--plain") as terminal, + ): + open_guide(terminal) + terminal.send("d") + terminal.wait("Download directory") + terminal.send(str(tmp_path)) + terminal.send("\r") + terminal.wait("Confirm download") + terminal.resize(30, 8) + terminal.wait("Resize") + terminal.send("y") + terminal.pump(0.2) + assert not list(tmp_path.iterdir()) + assert not any( + target == GUIDE_DOCUMENT["read_reference"] for _, target, _ in requests + ) + terminal.resize(80, 24) + terminal.wait("Confirm download") + reveal_detail(terminal, "Expected SHA-256") + assert not list(tmp_path.iterdir()) + terminal.send("y") + terminal.wait("Downloaded") + assert (tmp_path / f"{ACTOR}.md").read_bytes() == GUIDE_BYTES + terminal.quit() + + def test_guide_download_requires_confirmation_and_verifies_bytes(tmp_path): reads = guide_reads() with ( @@ -410,7 +482,7 @@ def test_guide_and_findings_remain_navigable_in_narrow_terminal(surface): terminal.wait("Locked instructions") else: open_findings(terminal) - terminal.wait("FINDINGS") + terminal.wait("[Findings]") terminal.wait(RUN) terminal.send("c") terminal.wait("argument: findings") @@ -499,10 +571,10 @@ def test_keyboard_journey_uses_only_nine_fresh_public_reads(): terminal.send("\r") terminal.wait(SECRET) terminal.send("\t") - terminal.wait("CONTEXT") + terminal.wait("[Context]") terminal.wait("guide-1") terminal.send("\t") - terminal.wait("INTAKE") + terminal.wait("[Intake]") terminal.wait("answer.md") terminal.send("\x1b") terminal.wait("Assigned terminal task") @@ -784,9 +856,11 @@ def test_hostile_text_resizing_plain_and_ctrl_c_restore_terminal( terminal.wait(SECRET) before = len(requests) terminal.resize(80, 24) - terminal.wait(TASK) terminal.wait(SECRET) + reveal_detail(terminal, TASK) assert TASK in terminal.screen + terminal.send("\x1b[5~") + terminal.wait(SECRET) assert len(requests) == before for forbidden in ( b"\x1b]52;c;", diff --git a/cli/tests/integration/test_tui_public_api.py b/cli/tests/integration/test_tui_public_api.py index e6a472d01..9dcffb06e 100644 --- a/cli/tests/integration/test_tui_public_api.py +++ b/cli/tests/integration/test_tui_public_api.py @@ -167,22 +167,22 @@ async def wait(text): await wait(str(ready_id)) await wait(direct["ready_task"]["description"]) terminal.send("\x1b") - await wait("READY") + await wait("[Ready]") terminal.send("\t") - await wait("MINE") + await wait("[Mine]") await wait(direct["mine"]["items"][0]["title"]) terminal.send("\r") await wait(task) await wait(direct["task"]["description"]) terminal.send("\t") - await wait("CONTEXT") + await wait("[Context]") await wait(direct["context"]["guide"]["version"]) terminal.send("\t") - await wait("INTAKE") + await wait("[Intake]") terminal.send("\x1b") - await wait("MINE") + await wait("[Mine]") terminal.send("\t") - await wait("SUBMISSIONS") + await wait("[Submissions]") await wait(direct["attempts"]["items"][0]["title"]) terminal.send("\r") await wait(submission_id) diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py index 53eebf2a5..8d87079fa 100644 --- a/cli/tests/integration/tui_terminal_support.py +++ b/cli/tests/integration/tui_terminal_support.py @@ -74,6 +74,7 @@ def __init__(self, origin, token, *args, columns=120, rows=40, extra_env=None): def _screen_setup(self, columns, rows): self.columns, self.rows = columns, rows + self.light_background = False self.raw = bytearray() self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") self.pending = "" @@ -169,7 +170,12 @@ def _render(self, text): sequence = match.group() self.pending = self.pending[match.end() :] if sequence.startswith("\x1b]11;?"): - self._write(b"\x1b]11;rgb:0000/0000/0000\x1b\\") + background = ( + "ffff/ffff/ffff" + if self.light_background + else "0000/0000/0000" + ) + self._write(f"\x1b]11;rgb:{background}\x1b\\".encode()) elif sequence.startswith("\x1b]10;?"): self._write(b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\") continue diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index c27ddc633..86fc66b04 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -1,485 +1,190 @@ -# Workstream terminal experience — implemented first slice and proposed extensions - -Status: WS-CLI-001-48 implements the bounded contributor read slice; -WS-CLI-001-49 extends it with findings and verified guide downloads below; -the broader workspace and mutation experience remains proposed. This is not a -new product/API contract. The opt-in `workstream tui` shares the existing Go -binary and public client. Explicit commands in [the CLI reference](../../cli/README.md) -remain the non-interactive interface for humans and agents. Implementation is -not a claim of deployment, completed native verification or public distribution. - -Source reconciliation: public operations below were checked against `main` -`7f6a71a69` on 2026-10-23. The release automation from -[WS-CLI-001-44](https://github.com/Flow-Research/workstream/blob/47760fa9dacf3d2000a073ee41c275c7c8b43ba6/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-44.md) -is merged; no public binary publication is established. The project catalogue -in [PR #665](https://github.com/Flow-Research/workstream/pull/665) is merged and -available to the explicit CLI, but is not used by this contributor workbench. -The first slice is reconciled with main `2e6cee003`; recheck merged -owners before implementing any later slice. - -## Product shape - -The terminal should feel like a precise control room: quiet dark slate, a cyan -focus line, peach for a deliberate action, and enough negative space to read the -work. The useful sequence is **browse → inspect → act → observe**. It is not a -simulation of system health, a second policy engine, or a dashboard of invented -scores. Show a fact only when a public response supplies it; label the time and -scope of each observation. No animation, percentage, or “complete” badge should -suggest that an asynchronous check, Review, acceptance, award, or fulfillment -has happened before the matching backend fact is observed. - -One launch opens the contributor workbench. Project selection comes from -`workstream me projects`, which lists projects where this actor has an exact -grant; it is not a public project directory. Within a selected project, `Ready` -uses `workstream task ready PROJECT_ID`, while `Mine` and `Submissions` use the -actor-owned collections filtered to that project. A person can change workspace -to Manager, Finance, Operator, or Audit explicitly. Each workspace uses its own -public routes and server-selected disclosure; a displayed role, permission or -`next_action` is never local authority to execute a later operation. If a -workspace's read is denied, show the denial, not an empty queue or a substitute -projection. An administrator with covering authority may not have an exact -project grant in `me projects`; Manager, Finance, Operator, and Audit therefore -also offer **Open exact project ID**. They do not pretend that recent local IDs -or `me projects` are a complete administrative project catalogue. There is no -contributor-wide project directory. Merged PR #665 -provides `GET /api/v1/projects` and `workstream project list`: one authorized -page of project ID, name and status, available only to current system-scoped -Project Manager, Operator, Finance Authority or Audit Authority readers. An -Access Administrator alone, exact-project administrative grants and contributor -grants do not enumerate this catalogue. This is the backing API for a later -system-reader picker, not a replacement for `me projects` -or a grant to read a guide or mutate a project. Exact-project managers/finance/ -audit readers still need selection through separately held contributor membership, -if any, or **Open exact project ID**; their administrative grant alone does not -populate `me projects`. A catalogue denial must never trigger a hidden fallback -request. System-reader -catalogue pages follow the same explicit pagination and error rules below. - -The TUI opens only on an interactive terminal; non-TTY use receives a bounded -error pointing to ordinary `workstream --help` and `--output json`. It uses the -same supplied `WORKSTREAM_API_URL` and `WORKSTREAM_TOKEN` contract as the CLI; -there is no Workstream login, token acquisition, keychain, browser callback or -credential store. It never prints a bearer. A project or resource ID may be -carried internally from a selected row to a detail/write request, eliminating -UUID transcription; that ID remains visible in the detail and confirmation. - -### Information architecture - -| Workspace | First surface | Inspect | Candidate deliberate action | -|---|---|---|---| -| Contributor | Exact-grant project picker; within it Ready, Mine, Submissions | Contributor-safe task detail, work context, locked guide and intake requirements; own preparation, Submission and checker evidence | Claim, start, prepare ZIP, then separately create Submission from a ready admission | -| Manager | Selected project's task queue and summary | Guide status/readiness, task/context, preparation counts, Submission/checker history | Create/screen/release a task; guide, import, grants or revision repair stay explicit CLI handoffs until their exact forms are designed | -| Finance | Selected project's current and retained ContributionPolicy versions | Exact policy rules and contribution evidence/earned award facts | Draft/update/publish/retire only through explicit reviewed forms; no payout control | -| Operator | Project-scoped checker retry sources, uncertain deliveries, and task/custody diagnostics | Exact source run/event/job and locked references | Explicit reasoned retry or reconciliation; no inferred recovery from a list | -| Audit | Exact project/task selector | Locked context and paged lifecycle evidence | Read only | - -This is navigation, not a promise that every candidate action ships in the -first TUI slice. The overall goal is the connected contributor and management -journeys below. The smallest first slice is the read-only contributor workbench -specified under Implementation fit; later slices add one reviewed operation -at a time until that journey can be performed without an implicit write chain. -Administrative actor/grant writes stay in the explicit CLI until separately -designed; there is no global actor picker or generic raw-HTTP console. Genuine -human Review decisions are not part of the current CLI inventory and must not -be pictured as working TUI actions. - -## Main screen, 120 × 40 - -Illustrative values and local observation time below are not current system -claims. `Ready` and `Mine` are distinct server projections; opening a row makes -a fresh exact-resource read. The 21-row drawing reaches at most 98 cells inside -a 120×40 terminal, leaving room for margins and long task text; the lower rows -become scrollable detail rather than decorative filler. +# Workstream contributor terminal workbench + +This is the presentation contract for the opt-in `workstream tui` contributor +workbench after [WS-CLI-001-50](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md). +It describes the intended merged interface, not a new API, deployed service, +published binary, or completed product journey. The existing read and download +boundaries are recorded in [WS-CLI-001-48](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) +and [WS-CLI-001-49](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md). +Explicit commands in the [CLI reference](../../cli/README.md) remain the +non-interactive interface for humans and agents, with their existing text, JSON, +arguments and exit behavior. + +## Scope and truth boundary + +One launch opens an exact-grant project picker, not a system project catalogue. +Within the selected project, visible collections are `Ready`, active `Mine`, +and owned `Submissions`. Ready offers fresh contributor-safe task detail. Active +Mine offers `Overview`, `Context`, `Intake`, and `Guide`; an owned Submission +offers `Overview` and paged `Findings`. Contribution Evidence is an inert CLI +handoff, not a fourth fetched collection. The project picker uses `me projects`; +the separate system-reader `project list` does not feed this workbench. + +The workbench makes no product mutation request: no claim, start, preparation, +Submission, Review, policy, management, payment, or recovery action. Its only +local file write is an explicitly confirmed, verified single-guide-document +download. It does not poll, automatically retry, cache stale authority, store +credentials or history, acquire tokens, launch a shell or viewer, or copy a +command to the clipboard. It uses the existing typed public client and the +supplied `WORKSTREAM_API_URL` and `WORKSTREAM_TOKEN`; there is no Workstream +login. A profile read may have the backend's existing actor admission, +last-seen and audit effects, so “read-only” describes product operations, not +an unaudited server interaction. + +Every displayed fact belongs to its returned scope. List rows are observations, +not reservations, current eligibility, or authority for a later command. +Checker runs and findings are evidence, not acceptance decisions. Only a stored +Submission outcome can say `review_pending`, `needs_revision`, `rejected` or +`accepted`; the TUI does not infer one from passing checks. Contribution records +are earned facts, not payment or fulfillment proof. The default locked +ReviewPolicy requires human review; supported false-policy acceptance is an +authorized backend operation, never a checker-owned or fabricated Review. + +## Reading and navigation + +The header identifies `WORKSTREAM / Contribute` and the selected project name. +The active collection and, where applicable, active detail section remain +visible with textual selection markers. At 120×40 the worklist occupies a +stable left pane (about 36 cells) and the main reading area occupies the right. +Only the right detail pane scrolls while reading task or Submission detail; +the left worklist retains its selected row. A guide confirmation replaces the +normal panes with a scrollable exact-target review. At widths of at least +100 cells and heights of at least 24 rows, use two panes. Smaller usable +terminals use sequential, single-column list and detail views; `--plain` +always uses that layout, even on a wide terminal. Reflow preserves selection, +exact project, section and confirmation target, and dispatches no request. +At less than 40×10, show only resize and `q`/Ctrl+C guidance: hidden content +cannot be selected and `y` cannot stage or dispatch a download. Restoring size +still requires deliberate confirmation. + +Excerpted 120×40 fixture render; ellipses below omit rows, not UI content. +This is terminal rendering evidence, not a live-backend claim: ```text - WORKSTREAM / CONTRIBUTE Project: Aster Research v 14:02 observed - ──────────────────────────────────────────────────────────────────────────────────────────────── - Ready 03 Mine 02 Submissions 01 Evidence / filter ? keys - - READY IN THIS PROJECT (page 1) │ SELECTED TASK - > Evaluate source evidence ready │ Evaluate source evidence - Trace failure modes ready │ Status: ready Est: 45 min - Test intake package ready │ Skills: analysis, testing - │ - Your active work │ Produce attributable findings for the - Draft result matrix in_progress │ supplied evidence. [more ↓] - Revise benchmark notes needs_revision│ - │ Locked terms: unpaid / exact awards - │ shown from the task's policy version - │ - │ NEXT Inspect task ↵ - │ Claim… a - │ - ──────────────────────────────────────────────────────────────────────────────────────────────── - ↑↓ select ↵ inspect a actions g project w workspace r refresh c CLI ? help q quit - Ready: observed 14:02 · page 1 · more available │ A read is not a reservation or authority +WORKSTREAM / Contribute / Aster Research / fixture project +Ready [Mine] Submissions +[Overview] Context Intake Guide +... +> Assess the coastal... [active] | Assess the coastal flood warning study and document where its evidence supports + | the published conclusions + | Stored status: in_progress + | Skills: evidence synthesis, research +... + | Instructions + | Read the supplied field report and produce an evidence matrix linking each major + | conclusion to its source. Keep direct observations separate from assumptions. +... + | Acceptance criteria + | Every conclusion has a traceable citation or an explicit evidence gap. The matrix +... + | Rejection criteria +... + | Contribution terms + | Accepted submission terms: money / USD / 40.000000000000000001 + | Completed review terms: unpaid + | Terms are potential earned terms, not acceptance or fulfillment. +... + | References + | Task: 30000000-0000-7000-8000-000000000001 +... +page 1 | loaded 1 | observed 18:09:50 +arrows scroll Tab section Esc back r refresh g projects c CLI ? help q quit ``` -The left column is a worklist, not a merged cross-project feed. Counts on the -top line refer only to the currently fetched page or to separately labelled -summary facts; do not present page length as a project-wide count. If a list is -paged, show `page 1 · more available` and let `n` fetch the next page with the -same project, filters, limit and opaque cursor. Never decode a cursor, silently -walk pages, or mix cursor families. Before exact detail loads, the right pane -says “Open to inspect”; ready summaries do not contain full instructions. - -Inside detail, use a short vertical sequence rather than modal nesting: -`Overview → Instructions → Locked context → Intake → History`. Long contents -scroll in one viewport. The locked guide's document labels, media types, sizes -and hashes are displayed from task context after an active assignment; download -is an explicit action to an existing directory, with no automatic PDF open or -pre-download integrity claim. The existing CLI verifies bytes during download. - -## Compact screen, 80 × 24 - -At narrow width, list and detail are sequential views, not cramped columns. -The same selection and exact project survive the transition. No essential state -is encoded by color or truncated-only text. This 18-row drawing's longest line -is 68 cells inside an 80×24 terminal. - -```text - WORKSTREAM / CONTRIBUTE Aster Research observed 14:02 - ──────────────────────────────────────────────────────────────── - Ready Mine Submissions Evidence / filter ? - - READY · page 1 · more available - > Evaluate source evidence ready - Trace failure modes ready - Test intake package ready - - Estimated: 45 min · analysis, testing - Locked terms: see task detail - - ↵ inspect selected task - a actions · g project · w workspace · n next page - - ──────────────────────────────────────────────────────────────── - ↑↓ select r refresh c CLI ? help q quit - A ready row is not reserved; claim is decided by Workstream. -``` - -Below roughly 70 columns or 20 rows, use a single plain list/detail view with -wrapped labels and vertical scrolling. If even the help/confirmation cannot be -displayed legibly, stop before any write and give the exact command path. Resize -must retain the selected resource but may reflow content; it must not commit an -action. Alternate-screen rendering, cursor keys, Enter, Tab, Esc, `?`, letters, -and Ctrl+C must work across supported Linux, macOS and Windows terminals without -Unix signals, shell-specific bindings, mouse, clipboard, or Unicode glyphs as -requirements. Mouse support may be additive only. - -## Keyboard drill and action contract - -1. `workstream tui` checks terminal/configuration and loads the actor's exact-grant - projects. `g` opens the project picker; `↑/↓`, type-to-filter fetched names, - and `Enter` choose one. Filtering is local to fetched rows and is labelled - so it cannot imply server-wide search. `w` changes workspace; a change never - executes a privileged call by virtue of a cached role. -2. `↑/↓` selects a row, `Enter` opens a fresh detail, `Tab` advances the - detail sections, `Esc` returns. `r` refreshes the current view and clears - stale cursor continuation. `/` filters the current fetched page, `n` loads - the next authorized page, and `?` shows all current keys. No rapid navigation - key invokes a write. -3. `a` opens only actions with a designed flow for the selected resource. A - server `next_action` may help label a candidate, but never proves eligibility. - `Enter` opens a review sheet containing action verb, exact project/task and - assignment, relevant locked policy/admission/revision identifiers, packet - fields or file selection, and the expected meaning of a successful receipt. - Before Claim, the sheet also shows the exact ContributionPolicyVersion and - both accepted-submission and completed-review terms from the fresh - contributor-safe task detail: each is either `unpaid` or the returned - instrument, unit and decimal-string quantity rows. If that detail refresh - is stale, denied, incomplete or failed, Claim cannot be confirmed. These - are potential earned terms, not a promise of acceptance or fulfillment. - `y` sends exactly one request **only while the confirmation itself has focus**; - pasted text and keys typed into a field cannot commit it. `Esc` is the - default cancellation. Once sent, disable duplicate dispatch from repeated - keys, resize events or network callbacks until the result is classified. - No action chains - claim → start, preparation → Submission, guide approvals → activation, or - checker result → acceptance. -4. After a known receipt, show its exact result and a separate `r` to observe - the current resource. After a canonical denial, show the server code and - correlation ID if present. After an uncertain write, freeze the action - context on a recovery sheet; do not auto-retry or offer a fresh key as the - primary action. The observation link is explicitly not rollback proof. -5. `c` displays an inert argv recipe for the equivalent existing CLI command, - with selected IDs as separate arguments. An optional copyable rendering may - use only an explicitly chosen and tested POSIX-shell or PowerShell quoting - mode; never assume that one quoted string works across platforms, especially - for packet text or paths. It does not launch a shell, place credentials in a - command, assume OS clipboard access, or imply that a TUI-only action exists - in the explicit CLI. If exact recovery inputs are not available, it says so - rather than fabricating a command. - -The footer uses a fixed key vocabulary. `q` warns before exit when a write is -unresolved; Ctrl+C always permits exit and preserves the private receipt. Neither -resolves or replays it. For a TUI write to -ship, a bounded private operation receipt must be committed **before** sending -the one request. It binds the API origin, current actor identity, action, exact -project/resource selectors, key or admission, packet/reason/revision inputs, -and a digest/fingerprint of any source file; it excludes the Flow bearer and -raw ZIP bytes. A source file must still be present and verified unchanged for -manual preparation replay. The receipt is app-private per operating-system -user (restrictive POSIX mode or Windows ACL), atomic against interruption, -bounded in count/size, and safe under concurrent TUI processes. Startup offers -an explicit recovery list only for the same origin and actor; it never sends a -write while reconciling. Expired receipts need a deliberate resolution or -privacy-preserving purge flow, not silent deletion or a new key. Exact storage -format, lifetime, path, filesystem protections, and source-fingerprint -semantics are an implementation decision gate to be reviewed before a mutation -slice. This is **TUI-only** state; the non-interactive CLI must remain stateless -and must not gain implicit files. Do not create a generic all-POST journal or -declare a history read sufficient for recovery. An interruption just before or -after sending is uncertain until an exact replay receipt or adequate -authoritative evidence establishes the outcome. - -### Contributor walkthrough and truth labels - -| Screen/step | Public owner and exact CLI path | Truthful label | -|---|---|---| -| Project and ready work | `me projects`; `task ready PROJECT_ID`; `task show TASK_ID` | “Available to inspect”, never “reserved” | -| Active assignment | `me assignments --project PROJECT_ID`; `task context TASK_ID`; `task requirements TASK_ID` | “Your current assignment”; fresh read may change | -| Claim/start | `task claim TASK_ID --idempotency-key UUID`; separate `task start TASK_ID --idempotency-key UUID` | “Claim recorded” / “Start recorded”, only after complete receipt | -| Locked instructions | `task context TASK_ID`; `task guide TASK_ID [--download DIR]` | “Locked guide”; bytes verified only after successful explicit download | -| Intake | `task prepare TASK_ID --assignment UUID --file ZIP --idempotency-key UUID` plus exact packet/revision flags | “Preparation”; `ready` receipt supplies admission; no Submission yet | -| Immutable attempt | `task submit TASK_ID --assignment UUID --admission UUID` plus the same packet | “Submission created”; not acceptance or a Review | -| Evaluation and outcome | `task preparations TASK_ID`, `task submissions TASK_ID`, `submission show SUBMISSION_ID`, `submission findings SUBMISSION_ID` | Checker findings are evidence; `accepted`, `needs_revision`, `rejected` and `review_pending` appear only from public stored outcome | -| Contribution evidence | `contributions --project PROJECT_ID` | Earned immutable facts and exact award terms, not proof of fulfillment | - -Preparation's packet is the exact assignment, optional predecessor, summary and -attestation, with an exact revision preparation ID/digest acknowledgement when -required. A `ready` admission can be used for the separate Submission request -with the same packet. Other preparation states are not success or permission to -submit; show their bounded status/findings and an observational read. A failed -intake does not create a Submission. For checker-requested revision, show the -current `revision_context` and exact parent pair, and warn that human Review -revision is not yet a delivered CLI journey. After submission, `review_pending` -means pending human review under the locked policy; passing checks do not make -the checker an acceptor. Under a supported false ReviewPolicy, required checks -can invoke the shared authorized FinalAcceptance, but the UI waits for the -stored outcome and ContributionRecord rather than predicting it. - -Unknown-response language must be operation-specific. Claim/start and most -keyed writes retain action, resource, reason/inputs and caller-owned key for -manual unchanged replay; a later read does not prove rollback. Preparation -requires the same task, assignment, ZIP bytes, packet, revision acknowledgement -and preparation key. Submission replay instead uses the exact admission and -packet—not a generic idempotency key. Preparation and Submission history do not -expose all original replay inputs, so their mere presence/absence cannot clear -an uncertain receipt. An expired/revoked Flow bearer or grant -can make even an exact replay fail; the TUI never treats a prior read or receipt -as authority. An observed `needs_revision` is not a prompt to silently submit a -new ZIP; the contributor inspects the current context and requirements first. - -Checker evidence is a separate view from outcome. Show each returned finding's -`check_id`, severity/status, message and suggested fix, and its optional -`path:line` only when present; do not synthesize a failed check from a missing -field. Distinguish blocking, warning and `coverage_incomplete` counts. A -coverage gap may route to `needs_revision` under a false ReviewPolicy rather -than acceptance; display the stored Submission outcome/reason, not a TUI verdict. - -### Management walkthrough and bounded handoffs - -Management is a connected setup → release → observation → diagnosis experience, -not a mixed-role dashboard. Workspace choice changes navigation only; a manager -cannot enter Finance or Operator by reusing a manager response. Until a step's -forms and exact recovery are implemented, `c` presents the existing command's -inert argv/input-file recipe and makes the handoff explicit. It never executes -that command or claims the whole flow is already a TUI capability. - -| Step | Existing public CLI operation | Screen or handoff boundary | -|---|---|---| -| Select and inspect | Exact-grant `me projects` or exact-ID entry; `project show`, `project access PROJECT_ID`; pending system catalogue as above | Current project identity and safe projection; access is observation, not a write grant | -| Declare and prepare guide | `project create`; `project guide create`, `edit`, `upload`, `setup`, `proposal`, `post-policy` | Exact project/guide/compilation/policy forms remain CLI handoffs; setup proposals are not runtime checker implementations | -| Lock approved rules | `project guide set-review-policy`, `set-revision-policy`, `approve-pre`, `approve-post`, `readiness`, `activate` | Separate operations and confirmations; retain default human review and explicit false-policy branch; readiness is not activation | -| Resolve guide feedback | `project guide sufficiency create`, `acknowledge-warnings`, `correct`, `correct-post`, `dispatch-correction` | Diagnostics/warnings are not approvals; correction dispatch needs its saved exact receipt, not a new generic key | -| Set contribution terms | Finance `project contribution-policy current`, `list`, `show`, `create`, `update`, `publish`, `retire` | Separate Finance workspace; publication is not guide binding, earning or fulfillment | -| Admit contributors and work | `project role-grant list`, `show`, `candidates`, `issue`, `revoke`; `project task create`, `screen`, `release`; `project task-batch` commands | Grants/import remain CLI handoffs; task release is separate from create/screen and locks the backend-owned context | -| Observe attempts | `project tasks`, `task`, `summary`, `assignments`, `preparations`, `submissions`, `submission`, `checker-runs` | Manager projections, not contributor-shaped substitutes; show exact locked contexts/requirements through their separate manager commands | -| Recover or audit | Operator `operations checker retry-sources`, `retry`, `operations delivery list`, `reconcile`, `operations artifact` commands; Audit `audit task locked-context`, `evidence` | Distinct authorized queues and exact-resource reads; no fabricated human Review or provider/payout controls | - -The remaining current guide/artifact-policy and revision-repair commands stay -available in the explicit CLI; listing a family here does not authorize a -generic JSON editor or imply every command is eligible for a TUI form. Human -Review/assignment and its revision journey, compensation fulfillment, and -runtime reputation remain separate product gaps, not empty working screens. - -For future Manager/Finance writes, retain exact action, project and resource -selectors, unchanged input contents/reason, and original caller key before -dispatch. Preserve `If-Match` on guide policy edits and any operation-specific -digest/lineage acknowledgement; a policy/detail read does not replace them. -Guide correction dispatch is different: use the saved correction receipt and -its exact project/guide/compilation, never synthesize an idempotency key. Operator -checker retry binds the exact terminal source run, project, reason and key; -delivery reconciliation binds the exact UNKNOWN event, project, reason and -key; artifact retry also binds the expected source-job CAS and task/optional -Submission selectors. None is interchangeable with another recovery action. -Before exposing each write, inspect its typed client and public schema and prove -the specific unknown-response path; no family-wide receipt shortcut is allowed. - -## Failure and accessibility states - -The status line distinguishes `Loading…`, `Observed HH:MM`, `Stale: refresh -failed`, and `Outcome unknown: recover deliberately`. Use a static ellipsis, -not a synthetic progress meter. Keep visible-screen read polling bounded and -explicit: initial load, user refresh, or a short documented observational -interval only on a visible pending preparation/evaluation screen; never poll -background project queues or mutate on a timer. Apply capped backoff after -transient failures and cancel obsolete reads on navigation. Tag each request -with a screen generation, actor, API origin and selected resource; discard late -responses that no longer match. A refresh does not overlap duplicate writes. -For the proposed foreground observation, start no sooner than five seconds -after the last completed read, then space subsequent pending-state reads at -10, 20 and at most 30 seconds; stop after two minutes on that screen and offer -manual `r`. Keep at most one read in flight. A transient failure uses the next -delay rather than a tight retry; a definitive denial stops polling immediately. -The implementation must validate this budget against the existing transport -timeouts and server limits before shipping. - -When a transient read fails, previously rendered data may remain only with a -prominent stale label and direct actions disabled until fresh inspection. On -401, 403 or a concealed 404, immediately clear protected detail and cached -pages for the affected scope and disable actions; do not leave prior private -content visible as if still authorized. A denied or concealed resource is not -a zero-item success. Show the error code/correlation ID when safe, without -echoing paths, token, raw network details or untrusted control bytes. For a -missing/rejected bearer, say “Save the recovery receipt if needed, exit, renew -the token in your existing environment, then relaunch.” A child process cannot -see environment changes made later in its parent shell; `r` alone cannot load -a renewed token. Do not offer login or claim to distinguish expiration from -other 401 causes unless the public error actually does. An actual local ZIP -read/upload can show bytes transferred against known file size, but reaching -100% is not server verification, ready admission or Submission creation; label -those as pending until their exact receipts/readbacks arrive. Do not invent -percentages for backend intake or post-submit evaluation. - -Use textual state words alongside color. Never blink. Respect `NO_COLOR`, -monochrome and low-color terminals; truecolor is a polish layer, not a -requirement. Suggested dark palette: slate canvas `#101923`, lighter panel -`#192632`, primary text `#EAF2F4`, muted text `#A9BAC3`, cyan focus `#69D7E3`, -peach action `#F2AE91`, amber caution `#E9C37B`, red error `#ED8991`. Select -light-background and ANSI-16 equivalents by terminal capability; all selection -and severity survive as ASCII marker plus label. Avoid half-width decorative -glyphs, terminal hyperlinks, inaccessible dim-only metadata, and color-as-status. -Untrusted project/task text is escaped, wrapped, and never interpreted as ANSI, -OSC links, shell commands, filesystem paths, or control sequences. A plain -single-column mode should be available for screen readers and troublesome -terminal emulators; it keeps the same keys, action confirmation and exact -failure wording, and does not fall back to a different authorization path. - -## Implementation fit and explicit gaps - -The existing Go binary has Cobra commands and a typed public client under -`cli/internal/api`; the TUI should call that client through the same validated -operations, not shell out to `workstream`, parse human text, introduce private -backend routes, or copy policy decisions. This is a candidate integration -boundary, not authorization to refactor the client. Official upstream -[Bubble Tea](https://github.com/charmbracelet/bubbletea/blob/main/README.md) -documents an event/model/update/view terminal architecture; -[Bubbles](https://github.com/charmbracelet/bubbles/blob/main/README.md) provides -list, viewport, help, and file-picker primitives; -[Lip Gloss](https://github.com/charmbracelet/lipgloss/blob/main/README.md) -supports terminal color-profile downsampling. The first slice selects Bubble -Tea 2.0.10, Bubbles 2.2.1 and Lip Gloss 2.0.6, with resolved modules in the CLI -lockfiles. Native terminal verification remains required on all four supported -targets; accessibility certification is not implied. - -### Original first slice (WS-CLI-001-48) - -The opt-in **read-only contributor workbench** in the existing Go binary provides: -launch/terminal guard → actor binding → `me projects` page → selected project's -Ready/active Mine/Submissions page → fresh task or owned Submission detail. -This first slice uses exactly nine existing typed client reads: `Profile`, -`MyProjects`, `ReadyTasks`, `MyAssignments`, `MySubmissions`, `ContributorTask`, -`WorkContext`, `SubmissionRequirements` and `Submission`, as bounded in -[WS-CLI-001-48](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md). -Ready opens contributor-safe task detail and both locked contribution terms; -active Mine additionally offers explicit context/intake sections. This is a UI -scope cut, not a claim that the backend context/requirements endpoints require -assignment. Guide document disclosure remains backend-owned; no guide bytes -are fetched. Owned Submission detail shows stored outcome and finding counts. -Detailed checker findings and contribution Evidence are clearly labelled -manual CLI handoffs only, not in-TUI evidence browsers or automatic reads. - -This slice includes explicit pagination, scoped refresh, stale/denied states, -local filtering labels, safe terminal text, 120×40/80×24 reflow, plain/no-color -mode, keyboard help and inert CLI handoff. No background polling is necessary -in this slice. It excludes all API writes, downloads/local export, automatic -token refresh, recovery files, new management forms, and the pending catalogue. -Use the existing actor profile read to bind the current actor; that GET may -perform the backend's existing admission/last-seen/audit effects, so “read-only” -does not mean an unaudited or side-effect-free backend. No explicit CLI output, -exit status, flags, help for existing commands, or implicit local state changes. - -The first slice makes no `cli/internal/api/` changes. A later in-TUI -contribution Evidence slice needs one bounded client prerequisite: a typed -single-page contribution read that returns validated records and the opaque -continuation. The current exported `Contributions(..., io.Writer)` walks up to -100 pages and discards the continuation in its merged output; it cannot back -explicit `n` navigation. Extend the existing client owner using its transport -and record validators, and let the existing complete-export CLI retain its -current behavior. Do not parse stdout, silently traverse pages in the TUI or -implement a duplicate endpoint client. Evidence stays an explicit manual CLI -handoff in the first slice; only a later slice implements and tests this port. -In that later slice, a page with a continuation must -produce exactly one request until the user asks for the next page. Retain the -existing multipage CLI export regression alongside that new proof. - -The first slice's bounded implementation record must select exact dependencies -and allowed files and name focused interaction, hostile-response, denial, -resize and terminal-teardown tests. It must not add the deferred typed-page -port or detailed-findings decoder. A later Evidence record must include only -that narrowly scoped shared typed-page prerequisite, not a general client refactor. -Proof must include a built TUI/public-API journey and unchanged explicit CLI -text/JSON/non-TTY behavior; render snapshots alone are not runtime proof. -This proposal is not authorization to begin that next change automatically. - -### Guide and findings extension (WS-CLI-001-49) - -[The bounded extension](../../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) -adds Guide after Overview/Context/Intake in Mine detail. Opening Guide fetches -fresh assignment-sensitive WorkContext. Arrows select a document in server -order; `d` enters an existing destination directory, Enter shows exact target -confirmation, and `y` starts the verified download. Metadata is not a verified -byte claim. Private staged bytes become a final UUID-named file only after -verification and receipt by the still-current screen; abort/quit cleanup does -not rely on a late response being rendered. Existing files are never replaced. -No automatic viewer, markdown execution or task examples are added. - -Submission detail Tab opens Findings using the existing validated checker-run -read. One page is loaded; `n` requests its explicit continuation and `r` resets -the findings read. Every run retains its identity/current flag and every finding -its status, severity, check ID, path/line, message and suggested fix when supplied. -Successful checks are not acceptance. Contribution Evidence remains a CLI -handoff. There are still no product writes, polling or operation journals. - -The shared API now returns the already-validated findings page/exact-run shape -alongside Raw; existing CLI output keeps Raw unchanged. The shared download -owner serves both explicit commands and TUI, preserving directory binding, -private staging, verified bytes and atomic no-overwrite publication. - -### Dependency map to the end-to-end experience - -| Boundary | Dependency and next usable result | -|---|---| -| Contributor read-only workbench foundation (-48) | Nine existing typed reads and merged backend guards → project/Ready/active Mine/Submissions browse/inspect with explicit pagination; extended by the guide/findings slice below; no catalogue dependency | -| Guide/findings extension | Existing WorkContext and verified document reader plus typed existing checker-run result → ordered guide selection, confirmed download and findings pagination; no backend changes or product writes | -| Later in-TUI contribution Evidence | Narrow shared typed single-page contribution port and preserved complete-export regressions → explicit per-page evidence browsing; not a first-slice prerequisite | -| Claim/start | Read workbench + reviewed per-operation private receipt and exact-key recovery → separate claim and start confirmations | -| Prepare/submit | Claim/start + ZIP source integrity + distinct preparation/admission replay receipts → separate intake and immutable Submission, then stored evaluation/outcome/contribution evidence | -| System workspace project picker | Merged #665 supplies the public API and explicit CLI; later TUI integration uses the four-role system catalogue only, retaining exact-grant/exact-ID entry for actors outside that scope | -| Management read workspaces | Existing Manager/Finance/Operator/Audit typed reads + explicit project selection → scoped queues, details and CLI handoffs; not blocked on catalogue if exact ID is known | -| Management writes | Each read workspace + that operation's reviewed form/authority/recovery proof → setup/release/policy/recovery actions individually, not an all-POST engine | -| Human Review or fulfillment | Stable public contracts and tested lifecycle guards for those specific journeys → separate design/implementation; not enabled by TUI or catalogue work | -| Public binary delivery | Merged #664 automation + protected first publication and independent install proof → distributable binary; neither prerequisite nor proof of local TUI correctness | - -The main mutation gate is implementing and proving the private, -operation-specific receipt boundary above for one-shot writes across TUI -crash/terminal loss. Do not expose a mutating action until that exact -operation's unchanged inputs and privacy boundaries are proven. -Other implementation gates: later TUI integration of the existing backend-owned -four-role project catalogue; real API-backed detail/denial tests, narrow terminal -layout snapshots at 120×40 and 80×24, keyboard-only and no-color checks on all -supported platforms, and proof that the explicit CLI/help/JSON behavior remains -unchanged. A TUI is an interface over currently local/backend capability, not -proof of hosted deployment, binary publication, human Review, or payout. +The drawing demonstrates hierarchy only: a row preview cannot contain full +instructions, criteria, locks, or terms before an exact detail read. In task +detail, show the full instructions and acceptance criteria before locked terms +and references, using wrapped paragraphs and readable spacing. Terms stay +potential and separate for an accepted Submission and completed Review, with +exact instrument, unit and decimal-string quantities from the locked version. +Retain exact task, assignment, Submission, policy and guide IDs, digests/hashes, +and decimal strings in their relevant detail sections; presentation must not +round, silently truncate, or replace them with a color or badge. Ready details +show only the fields actually returned for that scope. Mine Overview leads with +the work to do; Context, Intake and Guide expose their existing distinct reads. +Submission Overview presents the stored outcome/reason separately from Findings. + +Before inspection, the preview shows only facts already in the loaded row, +with an `Enter to inspect` cue. Moving a row or changing the preview makes no +request. `Enter` requests fresh exact-resource detail; opening Context, Intake, +Guide or Findings uses its existing scoped read. `n` requests one continuation +page, never walks pages in the background. `/` filters only loaded row titles +or names and says so; it is not search across pages. Refresh resets the current +page's cursor. The fixed status line identifies the resource, loaded row/run +count, current page, whether a continuation was returned, and local observation +time. It never presents loaded-page length as a total, a server timestamp, or +an inferred project-wide count. Loading is explicit. A failed read clears the +affected current content and continuation and reports unavailable or denied, +with safe server code/correlation ID when present; it never leaves old protected +content on screen with a merely cosmetic stale label. Session loss clears work +data and requires relaunch with a valid environment token. Late canceled reads +cannot restore a previous actor, project or resource. + +### Guide and Findings + +Guide lists the locked document metadata supplied to the active Mine context. +Metadata, including a returned hash, is **not** a verified-byte claim. `d` +opens destination entry for the selected document; the directory must already +exist. `Enter` advances to a scrollable confirmation that names the exact +document label and ID, task ID, destination path, UUID filename, expected byte +count and hash. It starts at the target heading. Only `y` with that confirmation +visible initiates the existing verified download. `Esc` cancels. The shared +download owner uses private staging, checks the bytes, then publishes one final +file without replacing an existing path; cancellation or failure cleans up and +does not report a verified save. No PDF/Office renderer or automatic open runs. + +Findings loads a single page for the selected owned Submission and retains each +run's exact ID and current flag. Display returned check ID, status, severity, +message, suggested fix and optional path/line without inventing missing +fields. Separate blocking, warning and `coverage_incomplete` evidence from the +stored Submission outcome. `n` fetches only the returned findings continuation; +`r` refreshes that read. Neither a pass nor a coverage gap is a TUI decision. + +### Keys and handoff + +`?` groups help by Navigate (arrows, `Tab`, `Esc`), Inspect (`Enter`, `r`, `g`, +`/`), Pages (`n`), Guide downloads (`d`, destination `Enter`, confirmation +`y`/`Esc`), and Exit (`c`, `q`, Ctrl+C). There are no independent pane-focus +keys. Show only applicable +keys in the contextual footer. `q` is ordinary text inside filter or directory +entry; Ctrl+C always exits. Help and inert handoff may scroll without changing +selection or sending a request. `c` shows the equivalent existing CLI command +as separate, inert argv arguments, never an executable shell string, and never +includes a bearer. Where no exact command recipe is available, say so. + +## Color, text and terminal support + +Use a quiet slate canvas, cyan focus and peach deliberate-action accent, with +readable light-background and ANSI-16 equivalents. Selection, severity and +action meaning also have words and markers. `NO_COLOR` disables application +color; `--plain` uses ASCII, no application colors or alternate screen, and +single-column layout. It still needs interactive cursor controls. The TUI +rejects non-TTY/JSON launch before an API request and points automation to +the explicit CLI. Wrapping and width calculations are in terminal cells, +including Unicode. At 40×10 and above, no essential label is available only +as clipped text. Untrusted response text cannot emit ANSI/OSC, controls or +bearers; prose may retain safe line breaks while IDs and labels stay strictly +escaped. Color does not signal authority, completion, or a synthetic progress +percentage. + +## Future boundary, not current behavior + +The broader [WS-CLI-001 initiative](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md) +retains separate in-TUI Contribution Evidence, product-write and management +boundaries. Evidence first needs a shared typed single-page contribution port; +the complete explicit CLI export is not a TUI page source. Human Review and +fulfillment are separate product gaps, not empty workbench screens. A later +system-reader picker may consume the distinct authorized project catalogue; +it must not turn exact-grant contributor membership into system discovery. + +Before any later TUI write ships, its specific public operation needs an +authorized, deliberate confirmation and durable private receipt of exact +unchanged inputs before one request, including actor/API origin and any +caller-owned key, admission, lineage acknowledgement or source-file digest. +An unknown response requires operation-specific recovery, never automatic +retry or inference from an empty history page. Claim/start keys, preparation's +ZIP/packet/key, Submission's admission/packet, guide correction receipts, and +Operator retry/reconciliation selectors are not interchangeable. Design and +prove each flow at its own boundary; do not add a generic write journal or +silent handoff to the current read workbench. The historical design decisions +and separate operation records remain in the [initiative overview](../../.commitrail/initiatives/WS-CLI-001/OVERVIEW.md). diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 26f144279..323598f7d 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -57,11 +57,14 @@ publisher. Publication and public-installation verification are tracked in [#562](https://github.com/Flow-Research/workstream/issues/562); a tag or passing build alone is not public distribution. The opt-in [contributor terminal workbench](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) -adds local read-only project/task/Submission navigation through existing typed -public reads. WS-CLI-001-49 adds paged detailed findings and explicit verified -locked-guide downloads. Contribution evidence remains an explicit CLI -handoff; TUI product mutations and management are separate work. Neither this interface -nor release automation establishes hosted operation or a published binary. +uses existing typed reads for exact-grant project, task and Submission navigation; +WS-CLI-001-49 adds findings pages and confirmed verified guide downloads. +[WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) +redraws that same local capability with visible navigation, readable instructions +and truthful loaded-page/evidence states, without new API or product operations. +Contribution Evidence remains an explicit CLI handoff; TUI product mutations +and management are separate. Neither this interface nor release automation +establishes hosted operation or a published binary. ## Product Goal @@ -590,7 +593,7 @@ cannot be reused as post-submission review-gate evidence. See the | Review decision and revision | **Checker revision implemented; human decisions planned** | Review/revision policy identities and mutation authority, with `requires_second_review` fixed false through typed and database boundaries; REV-04A immutable Review storage; REV-04B source storage; REV-04C hidden source-neutral FinalAcceptance/TASK/CON participant; ARCH-04E2-B source AUTH and complete database/audit/outbox closure for hidden automated outcomes. PILOT-08 supplies required project-policy N>=0, assignment-scoped attempts, immediate exhaustion/release, atomic complete-context rebase and acknowledgement, setup-blocked clock pause/resume, PM attention/repair, and durable timeout rejection/reclaim. The CLI now makes the existing exact manager repair POST available with caller-owned key and source, preserving backend eligibility and replay authority | Genuine human decision authority/composition and revision integration remain #554 work under the same intended policy; hosted operation remains unproven | | Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings and project-points units (internal Finance commands); public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete frozen award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction; 04F2 checker remediation and terminal infrastructure retry create no contribution or award. The bounded local public drill produced exactly one false-policy FinalAcceptance, submitter ContributionRecord and project-points award, with no Review | ARCH-04E2-B supplies exact routing authority/evidence, complete-set database closure and audit/outbox under REV-12A4A. Selected production request/completion delivery, 04F3 exact-cause replacement and the 04F4 owner with supported public terminal recovery are implemented; supported false-guide activation is implemented. Only actual Reviews create reviewer records. Obligation/root/ordinal custody remains deferred until fulfillment activation; no public recognition or fulfillment route is live | | Contribution evidence reads | **Public fixed-membership REST and complete CLI export** | PILOT-10A exposes current human self-read and one-project JSON/CSV export for covering Project Managers or exact-project Finance Authorities. Each page has fresh authority and one repeatable-read response transaction; selections through 100,000 contributions retain page-one membership behind signed scope/filter/format/limit-bound cursors, with complete zero/one/two immutable awards, exact decimals, recorded-time filters and formula-neutralized CSV. The CLI follows JSON or CSV continuation internally with unchanged filters, validates cross-page order/identity, and publishes only the complete JSON result or one private no-overwrite CSV | No recognition mutation, fulfillment, payout, deployed-service or published-binary claim | -| CLI and terminal interface | **Public CLI clients and opt-in contributor read workbench** | Existing explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) adds exact-grant project, Ready/active Mine/own Submission browsing and fresh details/context/intake with explicit pagination and fresh-denial handling. [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) adds locked-guide metadata, confirmed verified local downloads and paged checker findings through the shared client | Contribution Evidence remains a manual CLI handoff; a shared typed single-page contribution port precedes in-TUI Contribution Evidence. TUI product writes/recovery and management remain separate. Protected first binary publication and hosted journey proof remain outstanding | +| CLI and terminal interface | **Public CLI clients and opt-in contributor read workbench** | Existing explicit commands retain text/JSON/noninteractive behavior. [WS-CLI-001-48](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-48.md) supplies exact-grant project, Ready/active Mine/own Submission browsing and fresh details/context/intake with explicit pagination and denial handling. [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) supplies locked-guide metadata, confirmed verified local downloads and paged checker findings. [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) replaces only their presentation with visible collections/sections, readable instructions and accurate page/evidence labels | Contribution Evidence remains a manual CLI handoff; a shared typed single-page contribution port precedes in-TUI Evidence. TUI product writes/recovery and management remain separate. Protected first binary publication and hosted journey proof remain outstanding | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation; release configuration prepared** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export; hosted composition accepts only an optional HTTPS collector origin and keeps exporter outage outside product correctness | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove privacy, correlation and collector outage during the deployed release drill; no deployed monitoring claim | | Frontend and pilot | **Local runtime foundation; release configuration prepared; project inputs prepared** | An original [Terminal Systems guide, example and five task briefs](../pilots/terminal_systems/README.md) provide inputs for fresh project onboarding. The fixed five-task sample, its checker, private runtime continuation and sample-only proof workflow are retired. Checkout-isolated Docker Compose API, prefork Celery, Beat, PostgreSQL, Redis and MinIO remain available, alongside digest-only hosted release composition and the generic external image service. The generic image command tests project samples and publishes a verified digest to ECR without registering or activating it. The opt-in [local agent-loop overlay](engineering/local-agent-loop.md) adds that service, a separately built networkless proof checker and operator scripts; Docker Desktop (Apple Silicon) proof covers registration, checker execution and lifecycle activation, not this project’s requirements. Setup supports inference and explicitly manager-authored rules through the same governed proposal path. Inference is limited to PRE/POST specifications, with strict JSON and bounded correction; PM policy choices remain separate. The retained real Terminal Systems proposal preserves null project limits on the unchanged guide; its independent review predates the PRE/POST-only scope, with ten explicit implementation/runtime gaps and no bindings in that retained proposal. The [project-owned PRE image](../pilots/terminal_systems/checkers/README.md) now implements layout and metadata intake with local rule and networkless container samples; it has no task-name allowlist or shared-runner project code. The independent offline gVisor feasibility spike remains historical mechanism evidence | Obtain a fresh PRE/POST-only correction of the retained proposal, then reconcile the implemented PRE image, implement the remaining POST checks and resolve their runtime/material gaps; register the required images, obtain corrected policy bindings and separate approvals, then activate, import, screen and release tasks. Then prove the real contribution journey, runtime limits and interruption recovery on the intended local/hosted stack. Guide assets, image builds and former sample results are not live-project evidence. Hosted identity/AWS configuration, isolation, diagnostics, backup/restore and broader macOS runtime proof remain; the agent-loop overlay still needs approved guide bindings and a persistent-stack FinalAcceptance drill | @@ -933,6 +936,9 @@ cannot be reused as post-submission review-gate evidence. See the without changing product authority or adding product writes. [WS-CLI-001-49](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-49.md) adds confirmed verified locked-guide downloads and paged checker findings. + [WS-CLI-001-50](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md) + replaces the presentation of those same reads/downloads so contributors can + find instructions and distinguish observations from stored outcomes. Contribution Evidence remains a manual CLI handoff; broader TUI work remains separate. Further public commands require their own usable API contracts; binary-release automation is prepared, while protected first publication @@ -1370,6 +1376,8 @@ Delivered foundations (bounded local journey proven; no deployment claim) explicit Mine context/intake WS-CLI-001-49 -> locked-guide metadata + confirmed verified local download own Submission -> paged checker findings; Contribution Evidence -> manual CLI handoff + WS-CLI-001-50 -> visible contributor navigation + readable instructions/evidence + same typed reads/download; no new API, management or mutation capability later Evidence needs typed single-page port; writes need per-operation recovery task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff @@ -1502,10 +1510,11 @@ and award history never changes. ## Remaining Release Gates Client scope remains distinct from product completion: the local read-only TUI -does not complete human review, payment or hosted proof. In-TUI Contribution Evidence and -mutations are separate optional interface boundaries, not new backend lifecycle -prerequisites. CLI binary distribution still requires protected publication and -independent public-asset/install verification. +now presents the existing contributor reads and verified guide downloads more +clearly; it does not complete human review, payment or hosted proof. In-TUI +Contribution Evidence and mutations are separate optional interface boundaries, +not new backend lifecycle prerequisites. CLI binary distribution still requires +protected publication and independent public-asset/install verification. v0.1 is not ready until all of the following are true: From 5681030522fd8770ff209f15b38a3b21c7d7c0db Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 18:31:47 +0100 Subject: [PATCH 4/7] fix(cli): reconcile compact controls and exact context evidence --- cli/internal/tui/details.go | 6 +- cli/internal/tui/details_layout_test.go | 54 ++++++++++ cli/internal/tui/shell_layout_test.go | 127 ++++++++++++++++++++++++ cli/internal/tui/view.go | 38 ++++++- docs/design/cli-terminal-experience.md | 7 +- 5 files changed, 224 insertions(+), 8 deletions(-) diff --git a/cli/internal/tui/details.go b/cli/internal/tui/details.go index 25c85c82d..95495046e 100644 --- a/cli/internal/tui/details.go +++ b/cli/internal/tui/details.go @@ -320,12 +320,16 @@ func (m *Model) guideText() string { if len(v.GuideDocuments) == 0 { d.note("No assigned guide documents.") } + separator := " · " + if m.plain { + separator = " | " + } for index, original := range v.GuideDocuments { mark := " " if index == m.guideSelected { mark = "> " } - d.note(mark + fmt.Sprint(index+1) + ". " + m.clean(original.Label) + " · " + m.clean(original.MediaType) + " · " + humanBytes(original.ByteCount)) + d.note(mark + fmt.Sprint(index+1) + ". " + m.clean(original.Label) + separator + m.clean(original.MediaType) + separator + humanBytes(original.ByteCount)) } d.heading("References") d.line("Task", v.Task.TaskID) diff --git a/cli/internal/tui/details_layout_test.go b/cli/internal/tui/details_layout_test.go index 3b8a1e6f2..51eb1f8dc 100644 --- a/cli/internal/tui/details_layout_test.go +++ b/cli/internal/tui/details_layout_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/viewport" "github.com/Flow-Research/workstream/cli/internal/api" ) @@ -96,6 +97,59 @@ func TestGuideBrowseKeepsServerOrderAndSelectedExactMetadata(t *testing.T) { } } +func TestGuidePlainModeUsesASCIIChromeWithoutChangingUnicodeContent(t *testing.T) { + doc := api.TaskGuideDocument{DocumentID: actorID, Order: 1, Label: "Café 指南", MediaType: "text/markdown", ByteCount: 1024} + context := guideContext(doc) + m := &Model{context: &context, section: 3, plain: true, noColor: true, + started: true, detail: true, tab: mine, width: 80, height: 24, viewport: viewport.New()} + m.reflow() + rendered := m.View().Content + if !strings.Contains(rendered, "> 1. Café 指南 | text/markdown | 1.0 KiB") || strings.Contains(rendered, " · ") || strings.Contains(rendered, "\x1b") { + t.Fatal(rendered) + } +} + +func TestContextRetainsExactLockedPolicyAndRevisionReferences(t *testing.T) { + reviewHash := "sha256:" + strings.Repeat("a", 64) + revisionHash := "sha256:" + strings.Repeat("b", 64) + preparationHash := "sha256:" + strings.Repeat("c", 64) + predecessor, deadline := policyID, "2026-10-27T10:00:00Z" + context := guideContext() + context.ReviewPolicy = api.ContextPolicy{PolicyID: runID, Generation: 17, PolicyHash: reviewHash} + context.RevisionPolicy = api.ContextPolicy{PolicyID: findingID, Generation: 23, PolicyHash: revisionHash} + context.Lifecycle.NextActions = []string{"start"} + context.RevisionContext = &api.RevisionContext{ + PreparationID: actorID, PreparationSHA256: preparationHash, + PredecessorPreparationID: &predecessor, SourceSubmissionID: submissionID, + SourceRoutingManifestID: taskID, Sequence: 4, NextRevisionRound: 2, + MaxRevisionRounds: 3, RequestedAt: "2026-10-26T10:00:00Z", + DeadlineAt: &deadline, ClockState: "running", Outcome: "prepared", + Disposition: "needs_revision", Prior: api.RevisionSnapshot{GuideID: actorID}, + Target: &api.RevisionSnapshot{GuideID: policyID}, + Changes: []api.RevisionChange{{Component: "guide", Before: "v1", After: "v2"}}, + } + m := &Model{context: &context, section: 1, noColor: true} + rendered := m.detailText() + orderOf(t, rendered, "Assignment", "Task: "+taskID, "Project: "+projectID, "Assigned to current actor: true", "Observed action hints (not authority): start", "Locked rules", "Revision context") + for _, exact := range []string{ + "Guide: " + actorID, "Locked guide version: locked-v1", + "Review policy: " + runID, "Review policy generation: 17", "Review policy hash: " + reviewHash, + "Revision policy: " + findingID, "Revision policy generation: 23", "Revision policy hash: " + revisionHash, + "Contribution policy version: " + policyID, + "Revision preparation: " + actorID, "Revision preparation digest: " + preparationHash, + "Revision source Submission: " + submissionID, "Revision source routing manifest: " + taskID, + "Predecessor preparation: " + policyID, "Revision sequence: 4", "Next revision round: 2", + "Maximum revision rounds: 3", "Revision requested: 2026-10-26T10:00:00Z", + "Revision outcome: prepared", "Revision disposition: needs_revision", "Revision clock: running", + "Revision deadline: " + deadline, "Prior guide: " + actorID, "Target guide: " + policyID, + "Changed component: guide", "Before: v1", "After: v2", + } { + if !strings.Contains(rendered, exact) { + t.Fatalf("missing exact %q in:\n%s", exact, rendered) + } + } +} + func TestIntakeDisplaysConstraintsWithoutValidationClaim(t *testing.T) { desc, reason, fix := "Expected artifact", "Secrets forbidden", "Remove credentials" value := api.SubmissionRequirements{ diff --git a/cli/internal/tui/shell_layout_test.go b/cli/internal/tui/shell_layout_test.go index d3e434ec7..23a909ed7 100644 --- a/cli/internal/tui/shell_layout_test.go +++ b/cli/internal/tui/shell_layout_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "image/color" "reflect" @@ -9,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/Flow-Research/workstream/cli/internal/api" ) func TestWideShellKeepsSelectionOutsideScrollingDetails(t *testing.T) { @@ -156,3 +158,128 @@ func TestFilterEditingFooterMatchesEffectiveKeys(t *testing.T) { t.Fatal("Esc did not restore the previous local filter") } } + +func TestBelowMinimumTextModesAdvertiseEffectiveQuit(t *testing.T) { + m := boot(t, &fakeClient{}) + press(t, m, "enter") + press(t, m, "/") + m.Update(tea.WindowSizeMsg{Width: 30, Height: 8}) + if got := m.View().Content; !strings.Contains(got, "Ctrl+C quit") || strings.Contains(got, "q quit") { + t.Fatal("filter below minimum advertised an ineffective quit key", got) + } + press(t, m, "q") + if m.closed || m.filter != "q" { + t.Fatal("q should remain local filter input") + } + press(t, m, "ctrl+c") + if !m.closed { + t.Fatal("Ctrl+C did not quit during below-minimum filter editing") + } + + document := api.TaskGuideDocument{DocumentID: actorID, Label: "Original", MediaType: "text/plain"} + f := &fakeClient{context: func(context.Context, string) (api.Result[api.WorkContext], error) { + return api.Result[api.WorkContext]{Value: guideContext(document)}, nil + }} + guide := openMineGuide(t, f) + press(t, guide, "d") + guide.Update(tea.WindowSizeMsg{Width: 30, Height: 8}) + if got := guide.View().Content; !strings.Contains(got, "Ctrl+C quit") || strings.Contains(got, "q quit") { + t.Fatal("destination below minimum advertised an ineffective quit key", got) + } + press(t, guide, "q") + if guide.closed || guide.guideDirectory != "q" { + t.Fatal("q should remain download-directory input") + } + press(t, guide, "ctrl+c") + if !guide.closed { + t.Fatal("Ctrl+C did not quit during below-minimum destination editing") + } +} + +func TestCompactGuideFooterExposesWorkingDocumentAndScrollKeys(t *testing.T) { + documents := []api.TaskGuideDocument{ + {DocumentID: actorID, Label: "First original", MediaType: "text/plain"}, + {DocumentID: policyID, Label: "Second original", MediaType: "text/plain"}, + } + f := &fakeClient{context: func(context.Context, string) (api.Result[api.WorkContext], error) { + return api.Result[api.WorkContext]{Value: guideContext(documents...)}, nil + }} + m := openMineGuide(t, f) + m.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) + footer := m.footerText() + for _, key := range []string{"Up/Down doc", "PgUp/PgDn scroll", "d download", "Tab"} { + if !strings.Contains(footer, key) { + t.Fatalf("compact Guide footer lacks %q: %q", key, footer) + } + } + assertShellBounded(t, m, 40, 10) + press(t, m, "down") + if m.guideSelected != 1 { + t.Fatal("Down did not select next guide original") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if m.viewport.YOffset() == 0 { + t.Fatal("PgDown did not scroll the Guide viewport") + } + press(t, m, "d") + if m.guideMode != guideDestination { + t.Fatal("d did not enter download destination") + } + press(t, m, "esc") + press(t, m, "tab") + if m.section != 0 { + t.Fatal("Tab did not change Guide section") + } +} + +func TestCompactFindingsFooterExposesWorkingScrollAndConditionalNext(t *testing.T) { + m := boot(t, &fakeClient{}) + press(t, m, "enter") + press(t, m, "tab") + press(t, m, "tab") + press(t, m, "enter") + press(t, m, "tab") + m.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) + footer := m.footerText() + for _, key := range []string{"PgUp/PgDn scroll", "Tab"} { + if !strings.Contains(footer, key) { + t.Fatalf("compact Findings footer lacks %q: %q", key, footer) + } + } + if strings.Contains(footer, "n next") { + t.Fatal("Findings footer offered unavailable continuation") + } + assertShellBounded(t, m, 40, 10) + m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + if m.viewport.YOffset() == 0 { + t.Fatal("PgDown did not scroll Findings") + } + m.findingsCursor = "opaque-next" + if !strings.Contains(m.footerText(), "n next") { + t.Fatal("available Findings continuation is not advertised") + } + m.reflow() + assertShellBounded(t, m, 40, 10) + _, cmd := m.Update(keyMessage("n")) + if cmd == nil || m.findingsPage != 2 { + t.Fatal("n did not request exactly one next Findings page") + } + apply(t, m, cmd) + press(t, m, "tab") + if m.section != 0 { + t.Fatal("Tab did not change Findings section") + } +} + +func assertShellBounded(t *testing.T, m *Model, width, height int) { + t.Helper() + content := m.View().Content + if lipgloss.Height(content) > height { + t.Fatalf("view exceeds %d rows: %q", height, content) + } + for _, line := range strings.Split(content, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("view exceeds %d columns: %q", width, line) + } + } +} diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go index 301c5833c..599fc079e 100644 --- a/cli/internal/tui/view.go +++ b/cli/internal/tui/view.go @@ -80,6 +80,17 @@ func (m *Model) renderLabels(value string) string { return strings.Join(parts, " ") } +func (m *Model) renderFooter() string { + footer := m.footerText() + styles := m.styles() + for _, action := range []string{"d download", "y download"} { + if at := strings.Index(footer, action); at >= 0 { + return styles.secondary.Render(footer[:at]) + styles.action.Render(action) + styles.secondary.Render(footer[at+len(action):]) + } + } + return styles.secondary.Render(footer) +} + func (m *Model) footerText() string { if m.guideMode == guideDestination { return "Enter review Esc cancel Ctrl+C quit" @@ -116,7 +127,22 @@ func (m *Model) footerText() string { } if m.height < 16 { if m.detail { - return "scroll Esc back ? help q quit" + if m.tab == mine && m.section == 3 { + if m.context != nil && len(m.context.GuideDocuments) > 0 { + return "Up/Down doc PgUp/PgDn scroll\nd download Tab Esc back ? q quit" + } + return "PgUp/PgDn scroll Tab section\nEsc back ? help q quit" + } + if m.tab == submissions && m.section == 1 { + if m.findingsCursor != "" { + return "Up/Down PgUp/PgDn scroll Tab\nn next Esc back ? help q quit" + } + return "Up/Down PgUp/PgDn scroll Tab\nEsc back ? help q quit" + } + if m.tab == mine { + return "PgUp/PgDn scroll Tab section\nEsc back ? help q quit" + } + return "PgUp/PgDn scroll Esc back\n? help q quit" } if m.picker { return "Enter project ? help q quit" @@ -219,7 +245,11 @@ func (m *Model) View() tea.View { return tea.NewView("") } if m.tooSmall() { - v := tea.NewView(lipgloss.Wrap("Resize to at least 40x10. q quit", max(1, m.width), "")) + guidance := "Resize to at least 40x10. q quit" + if m.filtering || m.guideMode == guideDestination { + guidance = "Resize to at least 40x10. Ctrl+C quit" + } + v := tea.NewView(lipgloss.Wrap(guidance, max(1, m.width), "")) v.AltScreen = !m.plain return v } @@ -246,7 +276,7 @@ func (m *Model) View() tea.View { } else { lines = append(lines, m.viewport.View()) } - lines = append(lines, styles.secondary.Render(m.statusText()), styles.secondary.Render(m.footerText())) + lines = append(lines, styles.secondary.Render(m.statusText()), m.renderFooter()) v := tea.NewView(strings.Join(lines, "\n")) v.AltScreen = !m.plain return v @@ -289,7 +319,7 @@ func (m *Model) body() string { return m.handoffText() } if m.failure != "" { - return m.failure + "\nNo retained page or detail is available.\n" + m.failureHelp() + return m.styles().error.Render(m.failure) + "\nNo retained page or detail is available.\n" + m.failureHelp() } if m.loading { return m.loadingText() diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index 86fc66b04..d56a0e9bb 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -34,7 +34,7 @@ an unaudited server interaction. Every displayed fact belongs to its returned scope. List rows are observations, not reservations, current eligibility, or authority for a later command. Checker runs and findings are evidence, not acceptance decisions. Only a stored -Submission outcome can say `review_pending`, `needs_revision`, `rejected` or +Submission outcome can say `evaluation_pending`, `review_pending`, `needs_revision`, `rejected` or `accepted`; the TUI does not infer one from passing checks. Contribution records are earned facts, not payment or fulfillment proof. The default locked ReviewPolicy requires human review; supported false-policy acceptance is an @@ -53,7 +53,8 @@ normal panes with a scrollable exact-target review. At widths of at least terminals use sequential, single-column list and detail views; `--plain` always uses that layout, even on a wide terminal. Reflow preserves selection, exact project, section and confirmation target, and dispatches no request. -At less than 40×10, show only resize and `q`/Ctrl+C guidance: hidden content +At less than 40×10, show only resize and quit guidance (`q` ordinarily, +Ctrl+C during text entry): hidden content cannot be selected and `y` cannot stage or dispatch a download. Restoring size still requires deliberate confirmation. @@ -110,7 +111,7 @@ request. `Enter` requests fresh exact-resource detail; opening Context, Intake, Guide or Findings uses its existing scoped read. `n` requests one continuation page, never walks pages in the background. `/` filters only loaded row titles or names and says so; it is not search across pages. Refresh resets the current -page's cursor. The fixed status line identifies the resource, loaded row/run +page's cursor. The collection/section header identifies the resource; the fixed status line shows loaded row/run count, current page, whether a continuation was returned, and local observation time. It never presents loaded-page length as a total, a server timestamp, or an inferred project-wide count. Loading is explicit. A failed read clears the From 050d582191367ee9760044d0ec7a67a6093a463b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 18:51:35 +0100 Subject: [PATCH 5/7] fix(cli): keep narrow terminal controls bounded and diagnose native color --- .../initiatives/WS-CLI-001/WS-CLI-001-50.md | 5 ++++ cli/internal/tui/render_evidence_test.go | 2 +- cli/internal/tui/shell_layout_test.go | 29 ++++++++++++++++++- cli/internal/tui/view.go | 25 +++++++++++++--- cli/tests/integration/test_tui_http.py | 19 ++++++++---- cli/tests/integration/tui_terminal_support.py | 22 ++++++++++---- docs/design/cli-terminal-experience.md | 4 +-- 7 files changed, 88 insertions(+), 18 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md index 3bd65de43..ca2117922 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -110,3 +110,8 @@ in the new grouping. Native resize proof scrolls to exact references and back to instructions rather than requiring both to occupy the initial viewport. No test is removed or skipped. Representative renders are fixtures, not live product proof; native socket and signed-API/PostgreSQL journeys remain distinct checks. + +A narrow-but-tall 40x24 probe failed against the height-only compact-footer +selection. Compact controls now depend on width as well as height; boundary +reflow tests cover both sides of the compact and two-pane thresholds, and native +light/dark terminal checks cover 40x10 and 40x24 without extra requests. diff --git a/cli/internal/tui/render_evidence_test.go b/cli/internal/tui/render_evidence_test.go index 64633baf9..9d4efcc2c 100644 --- a/cli/internal/tui/render_evidence_test.go +++ b/cli/internal/tui/render_evidence_test.go @@ -95,7 +95,7 @@ func renderFixture(t *testing.T, screen string) (*Model, *fakeClient) { func TestRepresentativeRenderBoundsAndPureReflow(t *testing.T) { for _, screen := range []string{"projects", "ready", "mine", "intake", "guide-confirmation", "submission", "findings", "empty", "filter-miss", "denied", "help"} { for _, mode := range []string{"dark", "light", "no-color", "plain"} { - for _, size := range [][2]int{{120, 40}, {80, 24}, {40, 10}} { + for _, size := range [][2]int{{120, 40}, {100, 24}, {80, 24}, {60, 24}, {59, 24}, {40, 24}, {40, 10}} { name := fmt.Sprintf("%s-%s-%dx%d", screen, mode, size[0], size[1]) t.Run(name, func(t *testing.T) { m, f := renderFixture(t, screen) diff --git a/cli/internal/tui/shell_layout_test.go b/cli/internal/tui/shell_layout_test.go index 23a909ed7..8750e68c8 100644 --- a/cli/internal/tui/shell_layout_test.go +++ b/cli/internal/tui/shell_layout_test.go @@ -87,7 +87,7 @@ func TestCompactShellReflowPreservesStateAndUsesOneColumn(t *testing.T) { press(t, m, "enter") selected, page, generation := m.selected, m.page, m.generation before := len(f.calls) - for _, size := range [][2]int{{80, 24}, {40, 10}, {120, 40}, {80, 24}} { + for _, size := range [][2]int{{80, 24}, {40, 10}, {40, 24}, {59, 24}, {60, 24}, {99, 24}, {100, 24}, {109, 24}, {110, 24}, {120, 40}, {80, 24}} { m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) content := m.View().Content if lipgloss.Height(content) > size[1] { @@ -283,3 +283,30 @@ func assertShellBounded(t *testing.T, m *Model, width, height int) { } } } + +func TestEmptyAndFilteredListsKeepOnlyApplicableControls(t *testing.T) { + for _, screen := range []string{"empty", "filter-miss"} { + m, _ := renderFixture(t, screen) + m.plain, m.noColor = false, true + for _, size := range [][2]int{{40, 10}, {40, 24}, {80, 24}, {120, 40}} { + for _, more := range []bool{false, true} { + m.cursor = nil + if more { + cursor := "opaque-next" + m.cursor = &cursor + } + m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) + view := m.View().Content + if strings.Contains(view, "Enter") || strings.Contains(m.footerText(), "n next") != more { + t.Fatal("empty list advertises unavailable inspection or wrong continuation", view) + } + for _, key := range []string{"/ filter", "? help", "q quit"} { + if !strings.Contains(view, key) { + t.Fatal("empty list lost recovery key", key, view) + } + } + assertShellBounded(t, m, size[0], size[1]) + } + } + } +} diff --git a/cli/internal/tui/view.go b/cli/internal/tui/view.go index 599fc079e..091b56c10 100644 --- a/cli/internal/tui/view.go +++ b/cli/internal/tui/view.go @@ -125,7 +125,18 @@ func (m *Model) footerText() string { if m.loading { return "Loading... r refresh ? help q quit" } - if m.height < 16 { + if !m.detail && len(m.visible()) == 0 { + navigation := "/ filter r refresh" + if !m.picker { + navigation = "Tab collection " + navigation + } + exit := "? help q quit" + if m.cursor != nil { + exit = "n next " + exit + } + return navigation + "\n" + exit + } + if m.height < 16 || m.width < 60 { if m.detail { if m.tab == mine && m.section == 3 { if m.context != nil && len(m.context.GuideDocuments) > 0 { @@ -145,9 +156,15 @@ func (m *Model) footerText() string { return "PgUp/PgDn scroll Esc back\n? help q quit" } if m.picker { - return "Enter project ? help q quit" + if m.cursor != nil { + return "Up/Down select Enter project\n/ filter n next ? help q quit" + } + return "Up/Down select Enter project\n/ filter ? help q quit" + } + if m.cursor != nil { + return "Up/Down select Enter inspect\nTab / filter n next ? help q quit" } - return "Enter inspect / filter q quit" + return "Up/Down select Enter inspect\nTab collection / filter ? help q quit" } if m.width >= 110 { switch { @@ -370,7 +387,7 @@ func (m *Model) failureHelp() string { func (m *Model) previewText() string { selected, ok := m.selectedRow() if !ok { - return "Select a row to preview.\nEnter to inspect full details." + return "No row selected on this loaded page.\nUse / to adjust the local filter." } return "Selected on this page\n" + m.clean(selected.title) + "\nStatus: " + m.clean(selected.status) + "\nProject ID: " + m.clean(selected.project) + "\n\nEnter to inspect full details.\nA list summary is not full instructions or a reservation." } diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index 5a48276ce..3515f0266 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -191,15 +191,20 @@ def reveal_detail(terminal, text): @pytest.mark.parametrize("light", [False, True]) -def test_minimum_terminal_navigation_and_light_background(light): +@pytest.mark.parametrize("rows", [10, 24]) +def test_minimum_terminal_navigation_and_light_background(light, rows): reads = Reads() with ( socket_peer(reads) as (origin, requests), Terminal( - origin, TOKEN, columns=40, rows=10, extra_env={"COLORTERM": "truecolor"} + origin, + TOKEN, + columns=40, + rows=rows, + extra_env={"COLORTERM": "truecolor"}, + light_background=light, ) as terminal, ): - terminal.light_background = light open_ready(terminal) terminal.send("\r") terminal.wait("[Overview]") @@ -209,14 +214,18 @@ def test_minimum_terminal_navigation_and_light_background(light): calls = len(requests) terminal.resize(120, 40) terminal.pump(0.2) - terminal.resize(40, 10) + terminal.resize(40, rows) terminal.pump(0.2) assert len(requests) == calls, "resize dispatched a read" terminal.send("\x1b") terminal.wait("Ready terminal task") expected_focus = b"38;2;8;126;140" if light else b"38;2;105;215;227" assert expected_focus in terminal.raw, ( - "terminal background did not select its readable palette" + "terminal background did not select its readable palette", + { + "background_queries": terminal.background_queries, + "sgr": sorted(set(re.findall(rb"\x1b\[[0-9;:]*m", terminal.raw)))[:40], + }, ) terminal.quit() assert TOKEN.encode() not in terminal.raw diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py index 8d87079fa..1c270a7f3 100644 --- a/cli/tests/integration/tui_terminal_support.py +++ b/cli/tests/integration/tui_terminal_support.py @@ -57,11 +57,20 @@ class Terminal: behind a screen parser that ignores them. """ - def __init__(self, origin, token, *args, columns=120, rows=40, extra_env=None): + def __init__( + self, + origin, + token, + *args, + columns=120, + rows=40, + extra_env=None, + light_background=False, + ): assert os.name == "posix", "unsupported native terminal host" self.master, self.slave = pty.openpty() self.original = termios.tcgetattr(self.slave) - self._screen_setup(columns, rows) + self._screen_setup(columns, rows, light_background) self.resize(columns, rows, notify=False) self.process = subprocess.Popen( # noqa: S603 - built binary, fixed test argv [executable(), "tui", *args], @@ -72,9 +81,10 @@ def __init__(self, origin, token, *args, columns=120, rows=40, extra_env=None): start_new_session=True, ) - def _screen_setup(self, columns, rows): + def _screen_setup(self, columns, rows, light_background=False): self.columns, self.rows = columns, rows - self.light_background = False + self.light_background = light_background + self.background_queries = 0 self.raw = bytearray() self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") self.pending = "" @@ -170,6 +180,7 @@ def _render(self, text): sequence = match.group() self.pending = self.pending[match.end() :] if sequence.startswith("\x1b]11;?"): + self.background_queries += 1 background = ( "ffff/ffff/ffff" if self.light_background @@ -520,6 +531,7 @@ def __init__( rows=40, extra_env=None, redirect=None, + light_background=False, ): self.api = api = _WindowsAPI() self._handles = [] @@ -533,7 +545,7 @@ def __init__( self._temporary = tempfile.TemporaryDirectory(prefix="workstream-conpty-") self._receipt = Path(self._temporary.name) / "modes.json" self.process = None - self._screen_setup(columns, rows) + self._screen_setup(columns, rows, light_background) try: input_read, self._input = self._pipe() self._output, output_write = self._pipe() diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index d56a0e9bb..032b543f3 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -53,8 +53,8 @@ normal panes with a scrollable exact-target review. At widths of at least terminals use sequential, single-column list and detail views; `--plain` always uses that layout, even on a wide terminal. Reflow preserves selection, exact project, section and confirmation target, and dispatches no request. -At less than 40×10, show only resize and quit guidance (`q` ordinarily, -Ctrl+C during text entry): hidden content +At less than 40×10, show only resize and context-appropriate quit guidance. +`q` exits outside text entry; Ctrl+C always exits. Hidden content cannot be selected and `y` cannot stage or dispatch a download. Restoring size still requires deliberate confirmation. From b15ff492e3de121eb2d7558a788f15372592415a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 19:08:49 +0100 Subject: [PATCH 6/7] fix(cli): model native terminal scroll regions and background replies --- .../initiatives/WS-CLI-001/WS-CLI-001-50.md | 10 +++ cli/tests/integration/test_tui_http.py | 67 ++++++++++++++++++- cli/tests/integration/tui_terminal_support.py | 30 +++++++-- docs/design/cli-terminal-experience.md | 4 +- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md index ca2117922..ea53d0d9a 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -70,6 +70,8 @@ The existing design document becomes the single current presentation contract. L1: presentation of untrusted terminal text and exact download confirmations. Plan review before implementation; final focused security, QA/test-delta, architecture/reuse and documentation review on a clean pinned candidate. +Native proof-observer changes also receive CI-integrity review so a corrected +terminal fixture cannot silently weaken the platform evidence. No authorization policy or custody operation changes are authorized. The cohesive diff exceeds the preferred small-change line budget: the existing @@ -115,3 +117,11 @@ A narrow-but-tall 40x24 probe failed against the height-only compact-footer selection. Compact controls now depend on width as well as height; boundary reflow tests cover both sides of the compact and two-pane thresholds, and native light/dark terminal checks cover 40x10 and 40x24 without extra requests. + +Native rendering exposed two proof-host assumptions: the minimal screen observer +must apply the renderer's bounded scroll-region commands, and bare Windows +ConPTY does not expose the background query to the outer fixture. Preserve raw +hostile-output assertions while testing the observed scroll sequence directly. +Test neutral unknown-background output separately from a fixture-supplied terminal +background response; the latter still requires the exact light/dark palette. +Neither repair changes product authority, the renderer dependency, or CI selection. diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index 3515f0266..9c81f1b5b 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -5,6 +5,7 @@ import os import re import subprocess +import time from threading import Event from urllib.parse import parse_qs, urlsplit @@ -187,7 +188,9 @@ def reveal_detail(terminal, text): if text in terminal.screen: return terminal.send("\x1b[B") - assert text in terminal.screen, (text, terminal.screen) + assert text in terminal.screen, ( + f"missing {text!r} after scrolling:\n{terminal.screen}" + ) @pytest.mark.parametrize("light", [False, True]) @@ -219,7 +222,23 @@ def test_minimum_terminal_navigation_and_light_background(light, rows): assert len(requests) == calls, "resize dispatched a read" terminal.send("\x1b") terminal.wait("Ready terminal task") + if os.name == "nt": + # Bare ConPTY does not forward OSC 11 queries to its outer host. + # Until a terminal supplies a background report, the workbench + # must stay readable without assuming either palette. + assert terminal.background_queries == 0 + assert set(re.findall(rb"\x1b\[[0-9;:]*m", terminal.raw)) <= { + b"\x1b[m", + b"\x1b[0m", + } + background = "ffff/ffff/ffff" if light else "0000/0000/0000" + terminal.send(f"\x1b]11;rgb:{background}\x1b\\") + else: + assert terminal.background_queries > 0, "PTY did not answer OSC 11" expected_focus = b"38;2;8;126;140" if light else b"38;2;105;215;227" + deadline = time.monotonic() + 10 + while expected_focus not in terminal.raw and time.monotonic() < deadline: + terminal.pump(0.05) assert expected_focus in terminal.raw, ( "terminal background did not select its readable palette", { @@ -652,6 +671,52 @@ def test_terminal_observer_applies_character_deletion_and_reports_actual_cursor( assert terminal.screen.splitlines()[0] == "local filter: nothing-matches" +def test_terminal_observer_applies_native_scroll_region_down_sequence(): + terminal = object.__new__(Terminal) + terminal._screen_setup(80, 24) + terminal._write = lambda _: None + terminal._render( + "\x1b[1;1HHEADER\x1b[4;1HSECTION\x1b[5;1HTop detail" + + "\x1b[15;1H" + + TASK + + "\x1b[21;1HOld bottom\x1b[22;1HSTATUS" + ) + # Captured native renderer sequence when a 24-row detail viewport scrolls. + terminal._render("\x1b[5;21r\x1b[5;1H\x1b[6") + terminal._render("T\x1b[1;24r\x1b[5;1H") + lines = terminal.screen.splitlines() + assert lines[0] == "HEADER" + assert lines[3] == "SECTION" + assert lines[4] == "" + assert lines[10] == "Top detail" + assert lines[20] == TASK + assert lines[21] == "STATUS" + + +def test_terminal_observer_scrolls_on_line_feed_at_region_bottom(): + terminal = object.__new__(Terminal) + terminal._screen_setup(80, 24) + terminal._write = lambda _: None + terminal._render( + "\x1b[1;1HHEADER\x1b[4;1HSECTION\x1b[5;1HOld top" + + "\x1b[6;1HNext detail\x1b[20;1H" + + TASK + + "\x1b[21;1HOld bottom\x1b[22;1HSTATUS" + ) + # Captured native renderer sequence for one upward viewport scroll. + terminal._render("\x1b[5;21r\x1b[21;1H\n\x1b[1;24r\x1b[21;1H") + lines = terminal.screen.splitlines() + assert lines[0] == "HEADER" + assert lines[3] == "SECTION" + assert lines[4] == "Next detail" + assert lines[18] == TASK + assert lines[20] == "" + assert lines[21] == "STATUS" + terminal._render("\x1b[5;21r\x1b[24;1H\n") + assert terminal.screen.splitlines()[0] == "HEADER" + assert terminal.screen.splitlines()[21] == "STATUS" + + def test_explicit_pagination_refresh_and_local_filter_do_not_walk_pages(): reads = Reads() diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py index 1c270a7f3..9fbb135ad 100644 --- a/cli/tests/integration/tui_terminal_support.py +++ b/cli/tests/integration/tui_terminal_support.py @@ -91,6 +91,7 @@ def _screen_setup(self, columns, rows, light_background=False): self.sent_after = None self.x = self.y = 0 self.cells = [[" "] * columns for _ in range(rows)] + self.scroll_top, self.scroll_bottom = 0, rows - 1 def _write(self, data): os.write(self.master, data) @@ -103,6 +104,7 @@ def resize(self, columns, rows, *, notify=True): if notify: self.cells = [[" "] * columns for _ in range(rows)] self.x = self.y = 0 + self.scroll_top, self.scroll_bottom = 0, rows - 1 self.process.send_signal(signal.SIGWINCH) @property @@ -147,6 +149,23 @@ def _render(self, text): self.x = max(0, self.x - n) elif command == "G": self.x = min(self.columns - 1, n - 1) + elif command == "r" and not params.startswith(("?", "<", ">", "=")): + top = (values[0] or 1) - 1 + bottom = ( + (values[1] or self.rows) if len(values) > 1 else self.rows + ) - 1 + if 0 <= top < bottom < self.rows: + self.scroll_top, self.scroll_bottom = top, bottom + self.x = self.y = 0 + elif command == "T": + # Bubble Tea emits SD inside DECSTBM to shift only the + # detail viewport; header/status rows must stay put. + top, bottom = self.scroll_top, self.scroll_bottom + amount = min(n, bottom - top + 1) + retained = self.cells[top : bottom + 1 - amount] + self.cells[top : bottom + 1] = [ + [" "] * self.columns for _ in range(amount) + ] + retained elif command == "P": deleted = min(n, self.columns - self.x) row = self.cells[self.y] @@ -200,11 +219,11 @@ def _render(self, text): elif character == "\t": self.x = min(self.columns - 1, (self.x // 8 + 1) * 8) elif character == "\n": - self.y += 1 - if self.y == self.rows: - self.cells.pop(0) - self.cells.append([" "] * self.columns) - self.y -= 1 + if self.y == self.scroll_bottom: + self.cells.pop(self.scroll_top) + self.cells.insert(self.scroll_bottom, [" "] * self.columns) + else: + self.y = min(self.rows - 1, self.y + 1) elif character == "\b": self.x = max(0, self.x - 1) elif character >= " " and not unicodedata.combining(character): @@ -748,6 +767,7 @@ def resize(self, columns, rows, *, notify=True): self.columns, self.rows = columns, rows self.cells = [[" "] * columns for _ in range(rows)] self.x = self.y = 0 + self.scroll_top, self.scroll_bottom = 0, rows - 1 def _assert_restored(self): assert self._receipt.stat().st_size < 4096, "unbounded mode receipt" diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index 032b543f3..c95f07e07 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -157,7 +157,9 @@ includes a bearer. Where no exact command recipe is available, say so. Use a quiet slate canvas, cyan focus and peach deliberate-action accent, with readable light-background and ANSI-16 equivalents. Selection, severity and -action meaning also have words and markers. `NO_COLOR` disables application +action meaning also have words and markers. The terminal's background response +selects the palette; without that response, keep neutral terminal-default text +rather than assume a dark background. `NO_COLOR` disables application color; `--plain` uses ASCII, no application colors or alternate screen, and single-column layout. It still needs interactive cursor controls. The TUI rejects non-TTY/JSON launch before an API request and points automation to From 532f71db3dce11d145799b951e3f3502dd4482e1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 26 Oct 2026 19:23:19 +0100 Subject: [PATCH 7/7] test(cli): verify terminal colors at supported host boundaries --- .../initiatives/WS-CLI-001/WS-CLI-001-50.md | 9 ++++- cli/internal/tui/shell_layout_test.go | 12 ++++-- cli/tests/integration/test_tui_http.py | 39 ++++++++++--------- cli/tests/integration/tui_terminal_support.py | 13 ++++--- docs/design/cli-terminal-experience.md | 5 ++- 5 files changed, 48 insertions(+), 30 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md index ea53d0d9a..107ded66d 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-50.md @@ -122,6 +122,11 @@ Native rendering exposed two proof-host assumptions: the minimal screen observer must apply the renderer's bounded scroll-region commands, and bare Windows ConPTY does not expose the background query to the outer fixture. Preserve raw hostile-output assertions while testing the observed scroll sequence directly. -Test neutral unknown-background output separately from a fixture-supplied terminal -background response; the latter still requires the exact light/dark palette. +Bare Windows ConPTY proves neutral unknown-background output, not detected-color +transport: neither the query nor an injected response produced a usable round-trip +in the native fixture. POSIX PTYs prove the real query/response and exact light/dark +palette; an explicitly withheld response separately proves their neutral fallback. +Go tests on every matrix OS prove exact BackgroundColorMsg-to-palette rendering. +The unsupported unsolicited-response expectation is replaced by these explicit +proof boundaries without skipping any platform or changing production styling. Neither repair changes product authority, the renderer dependency, or CI selection. diff --git a/cli/internal/tui/shell_layout_test.go b/cli/internal/tui/shell_layout_test.go index 8750e68c8..e82f7b024 100644 --- a/cli/internal/tui/shell_layout_test.go +++ b/cli/internal/tui/shell_layout_test.go @@ -123,17 +123,23 @@ func TestNoColorAndUnknownBackgroundUsePlainDefaults(t *testing.T) { func TestDetectedLightBackgroundUsesReadablePaletteWithoutCanvas(t *testing.T) { m := boot(t, &fakeClient{}) + press(t, m, "enter") m.plain, m.noColor = false, false m.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 8, G: 14, B: 20, A: 255}}) dark := m.styles() + darkView := m.View() + if !strings.Contains(darkView.Content, "[Ready]") || !strings.Contains(darkView.Content, "38;2;105;215;227") || darkView.BackgroundColor != nil { + t.Fatal("dark background did not render exact focus color without a canvas", darkView.Content) + } m.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 250, G: 250, B: 246, A: 255}}) light := m.styles() + lightView := m.View() + if !strings.Contains(lightView.Content, "[Ready]") || !strings.Contains(lightView.Content, "38;2;8;126;140") || lightView.BackgroundColor != nil { + t.Fatal("light background did not render exact focus color without a canvas", lightView.Content) + } if !m.backgroundKnown || m.darkBackground || reflect.DeepEqual(dark.heading.GetForeground(), light.heading.GetForeground()) || light.selected.GetBackground() == nil { t.Fatal("light terminal did not switch semantic colors and selection surface") } - if m.View().BackgroundColor != nil { - t.Fatal("presentation forced a canvas over the terminal's own background") - } } func TestFilterEditingFooterMatchesEffectiveKeys(t *testing.T) { diff --git a/cli/tests/integration/test_tui_http.py b/cli/tests/integration/test_tui_http.py index 9c81f1b5b..96af3b5c1 100644 --- a/cli/tests/integration/test_tui_http.py +++ b/cli/tests/integration/test_tui_http.py @@ -193,7 +193,7 @@ def reveal_detail(terminal, text): ) -@pytest.mark.parametrize("light", [False, True]) +@pytest.mark.parametrize("light", [None, False, True]) @pytest.mark.parametrize("rows", [10, 24]) def test_minimum_terminal_navigation_and_light_background(light, rows): reads = Reads() @@ -223,29 +223,32 @@ def test_minimum_terminal_navigation_and_light_background(light, rows): terminal.send("\x1b") terminal.wait("Ready terminal task") if os.name == "nt": - # Bare ConPTY does not forward OSC 11 queries to its outer host. - # Until a terminal supplies a background report, the workbench - # must stay readable without assuming either palette. + # This bare ConPTY host did not expose the OSC 11 query or route a + # supplied report. It must remain neutral without a known color. assert terminal.background_queries == 0 + else: + assert terminal.background_queries > 0, "PTY did not request OSC 11" + if os.name == "nt" or light is None: + # The None fixture withholds the report even though a PTY query + # was observed. Unknown background must never assume a palette. assert set(re.findall(rb"\x1b\[[0-9;:]*m", terminal.raw)) <= { b"\x1b[m", b"\x1b[0m", } - background = "ffff/ffff/ffff" if light else "0000/0000/0000" - terminal.send(f"\x1b]11;rgb:{background}\x1b\\") else: - assert terminal.background_queries > 0, "PTY did not answer OSC 11" - expected_focus = b"38;2;8;126;140" if light else b"38;2;105;215;227" - deadline = time.monotonic() + 10 - while expected_focus not in terminal.raw and time.monotonic() < deadline: - terminal.pump(0.05) - assert expected_focus in terminal.raw, ( - "terminal background did not select its readable palette", - { - "background_queries": terminal.background_queries, - "sgr": sorted(set(re.findall(rb"\x1b\[[0-9;:]*m", terminal.raw)))[:40], - }, - ) + expected_focus = b"38;2;8;126;140" if light else b"38;2;105;215;227" + deadline = time.monotonic() + 10 + while expected_focus not in terminal.raw and time.monotonic() < deadline: + terminal.pump(0.05) + assert expected_focus in terminal.raw, ( + "PTY background report did not select its readable palette", + { + "background_queries": terminal.background_queries, + "sgr": sorted(set(re.findall(rb"\x1b\[[0-9;:]*m", terminal.raw)))[ + :40 + ], + }, + ) terminal.quit() assert TOKEN.encode() not in terminal.raw diff --git a/cli/tests/integration/tui_terminal_support.py b/cli/tests/integration/tui_terminal_support.py index 9fbb135ad..91980b276 100644 --- a/cli/tests/integration/tui_terminal_support.py +++ b/cli/tests/integration/tui_terminal_support.py @@ -200,12 +200,13 @@ def _render(self, text): self.pending = self.pending[match.end() :] if sequence.startswith("\x1b]11;?"): self.background_queries += 1 - background = ( - "ffff/ffff/ffff" - if self.light_background - else "0000/0000/0000" - ) - self._write(f"\x1b]11;rgb:{background}\x1b\\".encode()) + if self.light_background is not None: + background = ( + "ffff/ffff/ffff" + if self.light_background + else "0000/0000/0000" + ) + self._write(f"\x1b]11;rgb:{background}\x1b\\".encode()) elif sequence.startswith("\x1b]10;?"): self._write(b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\") continue diff --git a/docs/design/cli-terminal-experience.md b/docs/design/cli-terminal-experience.md index c95f07e07..698c71f2c 100644 --- a/docs/design/cli-terminal-experience.md +++ b/docs/design/cli-terminal-experience.md @@ -159,7 +159,10 @@ Use a quiet slate canvas, cyan focus and peach deliberate-action accent, with readable light-background and ANSI-16 equivalents. Selection, severity and action meaning also have words and markers. The terminal's background response selects the palette; without that response, keep neutral terminal-default text -rather than assume a dark background. `NO_COLOR` disables application +rather than assume a dark background. The bare Windows ConPTY test host exercises +this neutral fallback; it does not prove colored-host response transport. Native +POSIX tests exercise detected palettes, and model tests exercise exact palette +rendering on all supported operating systems. `NO_COLOR` disables application color; `--plain` uses ASCII, no application colors or alternate screen, and single-column layout. It still needs interactive cursor controls. The TUI rejects non-TTY/JSON launch before an API request and points automation to