Commit cfda5a3562

cfda5a35623f9504669de53ce21b27cfab9af116

parent: b3ad722721

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:58 UTC

docs/plans: CLI views stage 3

Ref #319

Layout: unified · split

docs/plans/2026-10-01-cli-views-stage3.md added +326
@@ -0,0 +1,326 @@
1# CLI views implementation plan (stage 3)
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Every command that still draws its terminal output through `table` or `view` draws a screen, and `view` keeps only its piped behaviour.
6
7**Architecture:** Stages 1 and 2 built `screen` (`internal/control/screen.go`), `emitView`/`emitPageView`, and eleven screens. This stage adds one helper, `listScreen`, and migrates the remaining 70 commands noun by noun: a builder per command (or per shared emitter), the command switched to `emitView`/`emitPageView`, its piped output pinned before and after. The last task strips `view`'s terminal branches, so a caller missed here falls back to the plain layout rather than a half-styled one.
8
9**Tech Stack:** Go, `internal/control`.
10
11**Spec:** `docs/specs/2026-10-01-cli-views-design.md`. Stage 1–2 plan: `docs/plans/2026-10-01-cli-views.md` (its "Corrections" section applies here too).
12
13**Issues:** #316 (tracking), #319 (this stage).
14
15## Global Constraints
16
17- Piped output and `--json` byte-identical for every migrated command; prove it with `pinPlain` before touching the command.
18- Rendering stays in `internal/control`; nothing in `cmd/gitbay` changes.
19- Colour is never the only signal (`stripSGR(colour) == colourless` holds for every screen; the renderer guarantees it if builders use cells, never raw SGR).
20- Every legend and `more` argv passes `checkActions` (registered path, declared flags).
21- Never put in a cell or legend: webhook secrets, API token values, push device tokens beyond `ShortToken`, mirror tokens, mailbox credentials, `web login` URLs, base64 page content. A legend command that reads a secret takes it from stdin and carries no value (`repo secret set <path> <NAME>`, never `--secret`).
22- Destructive commands appear in a legend without their confirmation flag (`release delete <repo> <tag>`, never `--yes`).
23- Commit messages end `Ref #319`; the last commit of the stage `Closes #319`. No attribution lines. Signed commits, `--strategy ff`.
24- Locally: `go build ./... && go vet ./... && go test ./internal/control/`. CI runs e2e, including `e2e/readonly_test.go`, which runs every read command at 60 columns and fails on a line wider than 60 (suggested commands are exempt only in unit tests; keep legend argv short).
25
26## Reference
27
28### Helpers that exist
29
30| Name | Where | Use |
31|---|---|---|
32| `screen`, `field`, `section{title,n,note,rows,more,empty}`, `row`, `rowOf`, `action{group,argv}` | `screen.go` | the model |
33| `c.emitView(data, plain, build)` / `c.emitPageView(p, items, next, plain, build)` | `control.go`, `cursor.go` | route terminal to `build` |
34| `discussion(cs)`, `events(cs)` | `screen.go` | comment and system-event sections |
35| `cRef cGlyph cYou cFlex cText cMeta cAge cNum cSize cSwatch cState cLink cMark` | `table.go` | cells |
36| `glyph(state)` | `term.go` | `✓` success/ok/approved/verified/passed/merged, `✗` failure/failed/error/bad sig, `◐` pending/running/queued, `○` closed/draft/canceled/skipped |
37| `labelsMark`, `checksMark`, `reviewMark` | `marks.go` | row marks |
38| `keyValues`, `c.usedText`, `c.expiresText` | `audit.go`, `identity.go` | reuse in builders |
39| `checkActions`, `pinPlain`, `screenCtx`, `renderString`, `sectionCounts`, `actionArgvs`, `actionVerbs` | `*_test.go` | tests |
40
41### Migration recipe (every command)
42
431. **Pin.** In the task's test file, add `Test<Cmd>PlainPinned`: build the fixture, `Dispatch` the command with no `Term`, `pinPlain(t, "<cmd-slug>", out.String())`. Run with `-update-plain`, then without. Commit nothing yet. If the screen tests in the same file do not compile yet, keep them out of the file until the pin exists (stage 2 lost a file juggling this; copy the full file aside with `cp` and check `grep -c 'func Test'` after restoring).
442. **Screen test.** Construct the command's data value directly (no store), call the builder, assert section titles and counts, the lead glyph of a representative row, the action argv, and `checkActions(t, s)`.
453. **Builder.** Write `<cmd>Screen(c *Ctx, ...) screen` next to the command, from the spec row below.
464. **Wire.** Replace `c.emit(d, plain)` with `c.emitView(d, plain, func() screen { return <cmd>Screen(...) })` (`emitPage` → `emitPageView`). Inside `plain`, delete every `c.Term.Cols > 0` branch and keep the `== 0` path unconditionally; a terminal no longer reaches `plain`. Move terminal-only data loads out of `plain` to a `c.Term.Cols > 0 && !c.JSON` block before the emit.
475. **Orphans.** `grep` every helper the deleted branches called; delete those now unused, with their tests.
486. **Run** `go test ./internal/control/ -run '<Cmd>'`, then the package. Pins must still match.
49
50### Worked example: a list with a shared emitter (`repo topics`, `repo topics add`, `repo topics remove`)
51
52`runRepoTopics` (`repo.go:963`) and `editTopics` (`repo.go:987`) both print `[]string` through a one-column table.
53
54Test (`internal/control/stage3repo_test.go`):
55
56```go
57func TestRepoTopicsPlainPinned(t *testing.T) {
58 st, repo, uid := newQueueTestRepo(t)
59 owner := store.User{ID: uid, Username: "alice"}
60 c, out, errOut := mrTestCtx(st, owner)
61 if code := Dispatch(c, []string{"repo", "topics", "add", repo.Path(), "cli", "forge"}); code != protocol.ExitOK {
62 t.Fatalf("exit %d: %s", code, errOut)
63 }
64 pinPlain(t, "repo-topics-add", out.String())
65 c, out, errOut = mrTestCtx(st, owner)
66 if code := Dispatch(c, []string{"repo", "topics", repo.Path()}); code != protocol.ExitOK {
67 t.Fatalf("exit %d: %s", code, errOut)
68 }
69 pinPlain(t, "repo-topics", out.String())
70}
71
72func TestTopicsScreen(t *testing.T) {
73 s := topicsScreen(screenCtx(80, false), screenRepo, []string{"cli", "forge"})
74 if n := sectionCounts(s); n["Topics"] != 2 {
75 t.Errorf("sections = %v", n)
76 }
77 if got := actionVerbs(s); !slices.Equal(got, []string{"topics", "topics"}) {
78 t.Errorf("actions = %q", got)
79 }
80 checkActions(t, s)
81}
82```
83
84Helper and builder (`screen.go` and `repo.go`):
85
86```go
87// listScreen is a list command's terminal screen: one section of rows
88// and the commands that apply.
89func listScreen(title string, rows []row, actions ...action) screen {
90 return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
91}
92```
93
94```go
95// topicsScreen is a repository's topics at a terminal, after a read or
96// an edit.
97func topicsScreen(c *Ctx, repo store.Repo, topics []string) screen {
98 rows := make([]row, len(topics))
99 for i, t := range topics {
100 rows[i] = rowOf(cRef(t))
101 }
102 return listScreen("Topics", rows,
103 action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
104 action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
105 )
106}
107```
108
109Both `runRepoTopics` and `editTopics` call `c.emitView(topics, plain, func() screen { return topicsScreen(c, repo, topics) })` (`now` in `editTopics`). An empty list still prints "nothing to list" on stderr: `emit` handles that before `build` runs.
110
111### Worked example: a show (`org show`, also serving `org members list`)
112
113```go
114// orgShowScreen is org show at a terminal: the organisation and its
115// members, and where to go from here.
116func orgShowScreen(c *Ctx, d orgShowOut) screen {
117 members := section{title: "Members", n: len(d.Members)}
118 for _, m := range d.Members {
119 members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role)))
120 }
121 return screen{
122 fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}},
123 sections: []section{members},
124 actions: []action{
125 {"Members", []string{"org", "members", "add", d.Org, "<user>"}},
126 {"Org", []string{"org", "team", "list", d.Org}},
127 {"Org", []string{"org", "label", "list", d.Org}},
128 {"Org", []string{"org", "milestone", "list", d.Org}},
129 },
130 }
131}
132```
133
134If `runOrgShow`'s output type is a local `type out struct`, hoist it to package level as `orgShowOut` (same JSON tags) as stage 2 did for `repoShowOut`. `org members list` delegates to `runOrgShow`, so it migrates with it.
135
136### Worked example: a stream with a header (`repo commit`)
137
138The screen has no diff part. `runRepoCommit` (`sig.go:250`) keeps `c.emit` and branches itself:
139
140```go
141 return c.emit(d, func(w io.Writer) {
142 if c.Term.Cols == 0 {
143 // existing plain header and diff, unchanged
144 return
145 }
146 c.render(w, commitScreen(c, repo, d, body))
147 fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
148 })
149```
150
151`commitScreen` fields: `Commit` (`cRef(sha10)`, `cText(subject)`), `Author` (`cText(name+" <"+email+">")`, `cAge(date)`), `Signature` (`cGlyph(state)`, `cState(state)`, `cMeta(signer, fingerprint)`), `URL` when `!c.Term.Links`; body = message without subject, format `"text"`; section `Checks` (`cGlyph(state)`, `cFlex(context)`); actions `Read: repo log <path> --ref <sha>`, `Read: repo tree <path> --ref <sha>`. The legend prints before the diff, which is what a reader scrolling up from the end of a long patch expects to find above it.
152
153## Spec rows
154
155Columns: **title** of the section (or "fields" for a show), **lead** (the glyph cell second in the row, after the ref, or none), **row** cells after the lead, **actions** (`Group: argv`), **notes**. `<p>` is `repo.Path()`.
156
157---
158
159## Task 1: `listScreen` and the repository lists (MR 3a)
160
161**Files:** `screen.go` (`listScreen`), `repo.go`, `pagescmd.go`, `deploykey.go`, `build.go` (secrets), `mirrorcmd.go`, `runnerrepo.go`, test `stage3repo_test.go`, pins under `testdata/plain/`.
162
163| Command | Title | Lead | Row | Actions | Notes |
164|---|---|---|---|---|---|
165| `repo topics` / `topics add` / `topics remove` | Topics | none | `cRef(t)` | worked example | shared `topicsScreen` |
166| `repo domain list` (`pagescmd.go:196`) | Pages domains | `cGlyph(state)` (`expired` → `"failed"`) | `cRef(domain)`, `cState(state)` | `Domains: repo domain add <p> <domain>`, `repo domain verify <p> <domain>` | |
167| `repo deploy-key list` (`deploykey.go:93`) | Deploy keys | none | `cRef(fp)`, `cState(mode)`, `cFlex(label)`, `cMeta(algo, "used "+usedText, expiresText)` | `Keys: repo deploy-key add <p>`, `repo deploy-key remove <p> <fingerprint>` | key arrives on stdin |
168| `repo secret list` (`build.go:555`) | Build secrets | none | `cRef(name)` | `Secrets: repo secret set <p> <NAME>`, `repo secret remove <p> <NAME>` | names only, never values |
169| `repo access list` (`repo.go:753`) | Access | none | `cRef(user)`, `cState(role)`, `cMeta("via "+source)` | `Access: repo access grant <p> <user> write`, `repo access revoke <p> <user>` | |
170| `repo mirror list` (`mirrorcmd.go:99`) | Mirrors | `cGlyph("error"/"pending"/"ok")` from LastError/Pending | `cRef(id)`, `cFlex(url)`, `cMeta(direction, "synced "+relAge(LastSync))`, `cMark(LastError, sgrRed)` | `Mirrors: repo mirror sync <p>`, `repo mirror remove <p> <id>` | never the token |
171| `repo runner list` (`runnerrepo.go:102`) | Runners | `cGlyph("running")` when BuildNumber≠0 | `cRef(fp)`, `cFlex(username)`, `cMeta(algo, seen, "building "+repo+" #n "+job)` | `Runners: repo runner remove <p> <fingerprint>` | |
172| `repo bookmarks` (`repo.go:1225`) | Bookmarks | none | `cLink(path, url)`, `cState(vis)`, `cFlex(desc)`, `cMeta(n+" bookmarks")` | `Read: repo show <first path>` | drop `cNum` so no header row |
173| `repo search` (`repo.go:1045`) | Repositories matching "q" | none | `cLink`, `cState(vis)`, `cFlex(desc)`, `cMeta(topics joined)` | `Read: repo show <first path>` | |
174| `repo list` (`repo.go:339`) | Repositories | none | `cLink`, `cState(vis[, archived])`, `cFlex(desc)` | `Read: repo show <first path>`, `New: repo create <owner>/<name>` | `emitPageView` |
175
176Steps: recipe per command; one commit for `listScreen` + topics, one per row group of three or four, each `Ref #319`.
177
178## Task 2: Repository browsing (MR 3a)
179
180**Files:** `sig.go` (log), `read.go` (tree, refs), `symbols.go`, test `stage3repo_test.go`.
181
182| Command | Title | Lead | Row | Actions | Notes |
183|---|---|---|---|---|---|
184| `repo log` (`sig.go:144`) | Commits on `<ref>` (+ " touching `<path>`") | `cGlyph(sig state)` | `cRef(sha10)`, `cFlex(subject)`, `cMeta(author, relAge(date))` | `Read: repo commit <p> <first sha>`, `repo tree <p> --ref <ref>` | delete the in-closure terminal table |
185| `repo tree` (`read.go:281`) | `<p>[/dir] at <ref>` | none | dirs `cMark(name+"/", sgrBlue)`, files `cText(name)`; `cSize`; `cMeta(sha10)` | `Read: repo cat <p> <dir/first file> --ref <ref>`, `repo log <p> --ref <ref> --path <dir>` | `sgrBlue` replaces the last `sgrCyan` in `read.go` |
186| `repo refs` (`read.go:~85`) | two sections, Branches and Tags | none | `cRef(name)`, `cMeta(sha10[, "default"])` | `Read: repo log <p> --ref <default>` | default branch first; tags from `sorted` |
187| `repo symbols` (`symbols.go:50`) | Symbols matching "q" | none | `cRef(name)`, `cText(kind)`, `cFlex(path:line)` | `Read: repo cat <p> <first path> --ref <ref>` | keep the two stderr notes; `emitPageView` |
188
189## Task 3: Repository shows (MR 3a, then open it)
190
191**Files:** `repo.go` (settings), `deps.go`, `sig.go` (commit), test `stage3repo_test.go`.
192
193| Command | Fields | Sections | Actions | Notes |
194|---|---|---|---|---|
195| `repo settings show` (`repo.go:783`) | `Repo` (`cRef(p)`), then one field per setting, `on`/`off` via `cState`; add the four not shown today (approvals, resolved, codeowners, website) to the **screen only** | none | `Protect: repo settings protect <p> <branch>`, `repo settings require-mr <p> on` (check the real arg form) | piped output unchanged |
196| `repo deps status` (`deps.go:85`) | `Checks` (`cGlyph("ok")`/`cGlyph("failed")` when LastError, `cText("on")`), `Last check` (`cAge`), `Error` (`cMark(…, sgrRed)`), `Tracked in` (`cLink("#n", …"issues"…)`) | Behind: `cRef(name)`, `cText(ecosystem)`, `cMeta(current+" → "+latest)` | `Deps: repo deps disable <p>` | the disabled branch is its own `emitView` with one field `Checks: off` and `Deps: repo deps enable <p>` |
197| `repo commit` (`sig.go:250`) | worked example | Checks | worked example | stream: keep `emit` |
198
199End of task: CHANGELOG line under Unreleased, push `cli-views-3a`, `gitbay mr create --source cli-views-3a --target main --title "control: repository screens (CLI views stage 3a)"`.
200
201## Task 4: Account (MR 3b)
202
203**Files:** `identity.go`, `register.go`, `sig.go` (pgp), `token.go`, `web.go`, `profile.go`, test `stage3account_test.go`.
204
205| Command | Title / fields | Lead | Row | Actions | Notes |
206|---|---|---|---|---|---|
207| `keys list` (`identity.go:117`) | SSH keys | `cYou()` on `c.Source`'s key | `cRef(fp)`, `cState(scope)`, `cFlex(label)`, `cMeta(algo, usedText, expiresText)` | `Keys: keys label <fingerprint> <text>`, `keys remove <fingerprint>` | `●` replaces the "this session" mark; add `"this session"` to its meta so the glyph is not the only signal |
208| `email list` (`register.go:58`) | Emails | `cYou()` when unverified | `cRef(addr)`, `cState(verified\|unverified)`, `cMeta("primary"?, VerifiedBy)` | `Email: email add <address>`, `email primary <address>` | |
209| `pgp list` (`sig.go:80`) | OpenPGP keys | `cGlyph("failed")` when revoked or expired | `cRef(fp)`, `cFlex(emails parsed from UIDsJSON)`, `cMeta("revoked"/"expires …")` | `Keys: pgp remove <fingerprint>` | parse UIDs for the screen only |
210| `token list` (`token.go:103`) | API tokens | `cGlyph("failed")` when expired | `cRef(name)`, `cState(scope)`, `cMeta("by "+createdBy, expiresText)` | `Tokens: token revoke <name>` | never a value |
211| `web sessions list` (`web.go:34`) | Browser sessions | none | `cRef(id)`, `cMeta("since "+relAge, "until "+relAge, usedText)` | `Sessions: web sessions revoke <id>` | never `web login` |
212| `whoami` (`identity.go:75`) | fields `User`, `Role` (admin only), `Instance`, `Key` (label or fp prefix), `Scope` | | | `Account: keys list`, `token list`, `email list` | move the `SSHKeyByFingerprint` lookup before emit |
213| `profile show`, `profile set`, `org profile` (shared `emitProfile`, `profile.go:254`) | fields `Profile` (`cText(name)`, `cMeta(kind, description)`), `Website`, `Activity`; body About | Links, Orgs, Members, Repos (`cLink`, `cState`, `cFlex`) | `Edit: profile set --description <text>` (own user profile only); `Edit: org profile <org> --description <text>` (org) | one builder; empty sections drop out after `set` |
214
215## Task 5: Notifications and webhooks (MR 3b)
216
217**Files:** `notifications.go`, `webhook.go`, test `stage3notify_test.go`.
218
219| Command | Title / fields | Lead | Row | Actions | Notes |
220|---|---|---|---|---|---|
221| `notifications list` (`notifications.go:452`) | Notifications | `cYou()` when unread | `cRef(repo)`, `cFlex(actor+" "+summary)`, `cMeta(kind, relAge)` | `Inbox: notifications read --all`; `Read: issue show`/`mr show <repo> <n>` for the first issue/MR row (number from the path's last segment) | keep the early empty-page exit; `emitPageView` |
222| settings `show`/`mail`/`reply`/`watch`/`push` (shared `emitNotificationSettings`, `:254`) | fields Mail, Reply, Watch, Push, each `cState("on"/"off")` | | | one action per setting offering the opposite value: `Settings: notifications settings mail off`, …; `Devices: notifications device list` | one builder serves five commands |
223| `notifications device list` (`:387`) | Push devices | none | `cRef(id)`, `cFlex(label)`, `cMeta(ShortToken, relAge(added))` | `Devices: notifications device remove <id>` | `ShortToken` only |
224| `webhook list` (`webhook.go:104`) | Webhooks | `cGlyph("ok")` active / `cGlyph("closed")` inactive | `cRef(id)`, `cFlex(url clipped to scheme+host+"/…")`, `cMeta(events, "signed"?)` | `Hooks: webhook deliveries <p>` | elide the URL path on the screen; piped unchanged |
225| `webhook deliveries` (`webhook.go:159`) | Deliveries | `cGlyph` with `delivered`→`"ok"` | `cRef(id)`, `cFlex(event)`, `cMeta(n+" attempts", "HTTP "+status)`, `cMark(LastError, sgrRed)` | `Retry: webhook redeliver <p> <first failed id>` | |
226
227## Task 6: Organisations, labels and milestones (MR 3b)
228
229**Files:** `org.go`, `orglabel.go`, `teams.go`, `label.go`, `milestone.go`, test `stage3org_test.go`.
230
231| Command | Title / fields | Lead | Row | Actions | Notes |
232|---|---|---|---|---|---|
233| `org list` (`org.go:91`) | Organizations | none | `cLink(org)`, `cState(role)` | `Read: org show <first org>` | |
234| `org show` + `org members list` | worked example | | | | |
235| `org label list` (`orglabel.go:142`) and `label list` (`label.go:41`) | Labels | none | `cSwatch(color)`, `cRef(name)`, `cMeta(n+" issues", m+" MRs", "org"?)` | org: `Labels: org label set <org> <label> --color rrggbb`; repo: `Labels: label set <p> <label> --color rrggbb`, `Read: issue list <p> --label <first>` | one `labelsScreen(c, labels, actions...)`; dropping `cNum` drops the header row |
236| `milestone list` + `org milestone list` (shared `emitMilestones`, `milestone.go:123`) | Milestones | `cGlyph(state)`; `cYou()` when open and overdue | `cRef(title)`, `cMeta(due or "overdue …", "c/t closed", "org"?)` | caller passes its actions: repo `Milestones: milestone create <p> <title>`, `milestone list <p> --state all`; org the `org milestone` equivalents | give `emitMilestones` an `actions []action` parameter |
237| `org team list` (`teams.go:141`) | Teams | none | `cRef(name)` | `Read: org team show <org> <first team>` | |
238| `org team show` (`teams.go:166`) | fields `Team` (`cLink(org/team)`), `Members` | Grants: `cLink(repo)`, `cState(role)` | `Team: org team add <org> <team> <user>`, `org team grant <org> <team> <owner/name> write` | |
239
240End of task: CHANGELOG line, push `cli-views-3b`, MR "control: account, notification and organisation screens (CLI views stage 3b)".
241
242## Task 7: Work items and content (MR 3c)
243
244**Files:** `query.go`, `search.go`, `dashboard.go` (feed), `mr.go` (revisions), `milestone.go` (templates), `build.go` (jobs), `status.go`, `release.go`, `snippet.go`, `wiki.go`, `explore.go`, `audit.go`, test `stage3work_test.go`.
245
246| Command | Title / fields | Lead | Row | Actions | Notes |
247|---|---|---|---|---|---|
248| `query run`, `issue list --query`, `mr list --query` (shared `runItemQuery`, `query.go:196`) | query name, or "Results" from `listByQuery` | `cGlyph(state or "draft")` | `cLink(ref)`, `cFlex(title)`, `cMeta(author, milestone, relAge(updated))` | `Read: issue show`/`mr show <repo> <n>` for the first row by kind | one builder, title passed in |
249| `query list` (`query.go:326`) | Saved queries | none | `cRef(name)`, `cFlex(query)`, `cMeta("pinned"?)` | `Queries: query run <first>`, `query pin <first>` | |
250| `query show` (`query.go:351`) | fields Query, Matches, Pinned | | | `Queries: query run <name>`, `query pin`/`unpin <name>` | |
251| `search` (`search.go:92`) | sections Repositories, Issues, Merge requests | `cGlyph(state)` for issue/MR rows | repo `cLink`, `cFlex(title)`; item `cLink(ref)`, `cFlex(title)`, `cMeta(author)` | `Narrow: search <q> --kind issue` | `writeSearchTable` stays for piped |
252| `feed` (`dashboard.go:340`) | Activity | none | `l.termCells(c)` | none | `emitPageView`; "Next page" covers paging |
253| `mr revisions` (`mr.go:2080`) | Revisions | `cMark("●", "")` for the current one, plain dot, not `cYou` | `cRef("v"+n)`, `cRef(sha10)`, `cAge` | `Compare: mr range-diff <p> <n>` | keep the stderr note |
254| `issue templates` (`milestone.go:288`) | Issue templates | none | `cRef(name)` | `New: issue create <p> --title <title>` | never the body |
255| `build jobs` (`build.go:443`) | Jobs | none | `cRef(name)`, `cMeta(when)` | `Run: build trigger <p> <first job>` | |
256| `status list` (`status.go:107`) | fields Commit, Combined (`cGlyph`+`cState`) | Statuses: `cGlyph(state)`, `cText(context)`, `cFlex(desc)`, `cMeta(creator)` | `Report: status set <p> <sha> --context <context> --state success` | drop the pseudo-row on the screen |
257| `release list` (`release.go:253`) | Releases | none | `cLink(tag)`, `cFlex(subtitle)`, `cMeta(n+" assets", relAge)` | `Read: release show <p> <first tag>` | `emitPageView` |
258| `release show` (`release.go:317`) | fields Release (`cRef(tag)`, `cText(title)`), Author, Released; body Notes | Assets: `cRef(name)`, `cSize`, `cMeta(sha12)` | `Get: release asset get <p> <tag> <first asset>`, `Edit: release edit <p> <tag> --title <title>` | rebind `repo` (it is `_` today) |
259| `snippet list` (`snippet.go:244`) | Snippets | none | `cRef(id)`, `cState(vis)`, `cFlex(desc)`, `cMeta(files, relAge)` | `Read: snippet show <first id>` | `emitPageView` |
260| `snippet show` (`snippet.go:212`) | fields Snippet, Owner, Visibility, Updated | Files: `cRef(name)`, `cSize` | `Get: snippet file get <id> <first file>`, `Edit: snippet edit <id> --visibility public` | never file content |
261| `wiki list` (`wiki.go:83`) | Wiki | none | `cLink(page)`, `cMeta("home"?)` | `Read: wiki show <p> <home>` | |
262| `wiki show` (`wiki.go:126`) | fields Page, File, URL; body Content (format md/org); binary pages get a `Binary` field and no body | | `Read: wiki list <p>` | never Base64 |
263| `explore` (`explore.go:60`) | Explore | none | `cLink(path)`, `cFlex(desc)`, `cMeta(topics, "archived"?)` | `Read: repo show <first path>` | `emitPageView` |
264| `audit` (`audit.go:35`) | Audit | none | `cAge`, `cText(actor)`, `cText(action)`, `cFlex(keyValues(data))` | `Filter: audit --since 24h` | |
265
266## Task 8: Administration (MR 3c)
267
268**Files:** `admin.go`, `adminhost.go`, `adminmail.go`, test `stage3admin_test.go`.
269
270| Command | Title / fields | Lead | Row | Actions | Notes |
271|---|---|---|---|---|---|
272| `admin user list` (`admin.go:134`) | Accounts | `cYou()` when pending | `cRef(name)`, `cState(state)`, `cMeta("admin"?, "seen "+relAge)` | `Filter: admin user list --state pending` | `emitPageView` |
273| `admin user show` (`admin.go:175`) | fields User (`cRef`, `cState`, `cMeta("admin"?)`), Seen, Repos (+limit), Sessions | Keys, Emails, PGP keys, Orgs, API tokens | `Manage: admin user disable <name>` or `enable`, `admin user promote`/`demote <name>` by current state | token names and scopes only |
274| `admin runners` (`admin.go:540`) | fields Queue (`cText(n+" pending")`, `cMeta(claimed, wait avg, wait max, reaped)`) | Runners: lead `cGlyph("running")` when holding; `cRef(user)`, `cMeta(scope, seen, held)` | `Prune: admin runners remove <fingerprint>` (first idle runner) | never `forget` |
275| `admin repo list` (`admin.go:407`) | Repositories | none | `cLink(path)`, `cState(vis[, archived])`, `cSize(bytes)`, `cMeta("pushed "+relAge)` | `Filter: admin repo list --visibility private` | keeps the header (size column) |
276| `admin mr prune` (`admin.go:616`) | Pruned | `cGlyph("ok")` / `cGlyph("skipped")` for "already gone" | `cLink("!n")`, `cRef(head)` or `cMeta("already gone")` | none | |
277| `admin stats` (`adminhost.go:291`) | fields Users, Orgs, Repos, Issues (+open), MRs (+open), Disk (`cSize(db)`, `cMeta(repos, lfs)`) | Repositories: top 20 by bytes, `cLink(path)`, `cSize`; `more: admin repo list` | `Admin: admin user list`, `admin runners` | the dashboard's Problems line points here |
278| `admin mail inbound check` (`adminmail.go:25`) | fields Inbound (server / mailbox), Messages (`cMeta(unseen)`), Auth (`cMark("✗ …", sgrRed)` on warning, else `cMark("✓ dkim", sgrGreen)`) | | none | off: one field `Inbound: off` |
279
280## Task 9: `view` becomes piped-only; docs; MR 3c
281
282**Files:** `view.go`, `view_test.go`, `.gitbay/wiki/Users.org`, `CHANGELOG.org`.
283
284- [ ] **Step 1: Check nothing is left.** Run the stage-2 inventory again:
285
286```bash
287cd internal/control && grep -lE 'c\.table\(|c\.view\(' *.go | grep -v _test
288```
289
290and, for each file, confirm every `c.view(`/`c.table(` sits inside a `plain` closure passed to `emitView`/`emitPageView`, or behind `c.Term.Cols == 0`. List any exception in the MR body.
291
292- [ ] **Step 2: Failing test.**
293
294```go
295// view is the piped writer: at a terminal it writes what it writes
296// piped, so a caller that never migrated falls back to the plain layout.
297func TestViewIgnoresTerminal(t *testing.T) {
298 render := func(term Term) string {
299 var b bytes.Buffer
300 c := &Ctx{Term: term}
301 v := c.view(&b)
302 v.title("#1", "A title", "open")
303 v.fields("author", "alice")
304 v.section("files")
305 return b.String()
306 }
307 if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want {
308 t.Errorf("terminal view differs from piped:\n%q\n%q", got, want)
309 }
310}
311```
312
313- [ ] **Step 3: Strip `view`'s terminal branches.** In `view.go`: `title`, `section`, `fields`, `body`, `event`, `comment` keep only their `Cols == 0` paths; `opts` returns `termtext.Options{Base: …}` without width or colour; `when` keeps its terminal format only if a screen still calls it (`grep -n 'c.when(' *.go`), otherwise its piped form only. `safe()` calls stay: they are no-ops piped.
314
315- [ ] **Step 4: Run** the package; fix any test that asserted a terminal `view` (each is now a screen test or a piped test).
316
317- [ ] **Step 5: Docs.** Users wiki: replace the "other `show` views" bullet with "every list and show is a screen; `--json` and piped output are unchanged", and drop the per-command lists added in stage 2. CHANGELOG: one line for 3c.
318
319- [ ] **Step 6: Verify, commit `Closes #319`, push `cli-views-3c`, MR** "control: work item and admin screens; view is piped-only (CLI views stage 3c)".
320
321## Self-review notes
322
323- Coverage: the 70 commands from the inventory (25 nouns) appear in tasks 1–8, the eleven `view` callers outside the original list included (`admin mail inbound check`, `repo deps status`, `org show`, `admin stats`, `repo commit`, `org team show`, `emitProfile`, notification settings, `repo settings show`, `admin user show`, `admin runners`); task 9 removes `view`'s terminal branches.
324- Shared emitters each get one builder: `editTopics`, `emitProfile`, `emitNotificationSettings`, `emitMilestones`, `runOrgShow`, `runItemQuery`, label lists.
325- Rows give exact cells and actions; code is written out in full for the helper and the three patterns (list with shared emitter, show, stream with header). The per-command builders follow those patterns from their table rows; that is a deliberate trade against 70 near-identical code blocks.
326- Argument forms marked "check the real arg form" (`repo settings require-mr`) are verified by `checkActions` in the screen test; adjust the argv to whatever passes.