docs/plans/2026-10-01-cli-views-stage3.md
326 lines · 28203 bytes
CLI views implementation plan (stage 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.
Goal: Every command that still draws its terminal output through table or view draws a screen, and view keeps only its piped behaviour.
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.
Tech Stack: Go, internal/control.
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).
Issues: #316 (tracking), #319 (this stage).
Global Constraints
- Piped output and
--jsonbyte-identical for every migrated command; prove it withpinPlainbefore touching the command. - Rendering stays in
internal/control; nothing incmd/gitbaychanges. - Colour is never the only signal (
stripSGR(colour) == colourlessholds for every screen; the renderer guarantees it if builders use cells, never raw SGR). - Every legend and
moreargv passescheckActions(registered path, declared flags). - Never put in a cell or legend: webhook secrets, API token values, push device tokens beyond
ShortToken, mirror tokens, mailbox credentials,web loginURLs, 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). - Destructive commands appear in a legend without their confirmation flag (
release delete <repo> <tag>, never--yes). - Commit messages end
Ref #319; the last commit of the stageCloses #319. No attribution lines. Signed commits,--strategy ff. - Locally:
go build ./... && go vet ./... && go test ./internal/control/. CI runs e2e, includinge2e/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).
Reference
Helpers that exist
| Name | Where | Use |
|---|---|---|
screen, field, section{title,n,note,rows,more,empty}, row, rowOf, action{group,argv} |
screen.go |
the model |
c.emitView(data, plain, build) / c.emitPageView(p, items, next, plain, build) |
control.go, cursor.go |
route terminal to build |
discussion(cs), events(cs) |
screen.go |
comment and system-event sections |
cRef cGlyph cYou cFlex cText cMeta cAge cNum cSize cSwatch cState cLink cMark |
table.go |
cells |
glyph(state) |
term.go |
✓ success/ok/approved/verified/passed/merged, ✗ failure/failed/error/bad sig, ◐ pending/running/queued, ○ closed/draft/canceled/skipped |
labelsMark, checksMark, reviewMark |
marks.go |
row marks |
keyValues, c.usedText, c.expiresText |
audit.go, identity.go |
reuse in builders |
checkActions, pinPlain, screenCtx, renderString, sectionCounts, actionArgvs, actionVerbs |
*_test.go |
tests |
Migration recipe (every command)
- Pin. In the task's test file, add
Test<Cmd>PlainPinned: build the fixture,Dispatchthe command with noTerm,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 withcpand checkgrep -c 'func Test'after restoring). - 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). - Builder. Write
<cmd>Screen(c *Ctx, ...) screennext to the command, from the spec row below. - Wire. Replace
c.emit(d, plain)withc.emitView(d, plain, func() screen { return <cmd>Screen(...) })(emitPage→emitPageView). Insideplain, delete everyc.Term.Cols > 0branch and keep the== 0path unconditionally; a terminal no longer reachesplain. Move terminal-only data loads out ofplainto ac.Term.Cols > 0 && !c.JSONblock before the emit. - Orphans.
grepevery helper the deleted branches called; delete those now unused, with their tests. - Run
go test ./internal/control/ -run '<Cmd>', then the package. Pins must still match.
Worked example: a list with a shared emitter (repo topics, repo topics add, repo topics remove)
runRepoTopics (repo.go:963) and editTopics (repo.go:987) both print []string through a one-column table.
Test (internal/control/stage3repo_test.go):
func TestRepoTopicsPlainPinned(t *testing.T) {
st, repo, uid := newQueueTestRepo(t)
owner := store.User{ID: uid, Username: "alice"}
c, out, errOut := mrTestCtx(st, owner)
if code := Dispatch(c, []string{"repo", "topics", "add", repo.Path(), "cli", "forge"}); code != protocol.ExitOK {
t.Fatalf("exit %d: %s", code, errOut)
}
pinPlain(t, "repo-topics-add", out.String())
c, out, errOut = mrTestCtx(st, owner)
if code := Dispatch(c, []string{"repo", "topics", repo.Path()}); code != protocol.ExitOK {
t.Fatalf("exit %d: %s", code, errOut)
}
pinPlain(t, "repo-topics", out.String())
}
func TestTopicsScreen(t *testing.T) {
s := topicsScreen(screenCtx(80, false), screenRepo, []string{"cli", "forge"})
if n := sectionCounts(s); n["Topics"] != 2 {
t.Errorf("sections = %v", n)
}
if got := actionVerbs(s); !slices.Equal(got, []string{"topics", "topics"}) {
t.Errorf("actions = %q", got)
}
checkActions(t, s)
}
Helper and builder (screen.go and repo.go):
// listScreen is a list command's terminal screen: one section of rows
// and the commands that apply.
func listScreen(title string, rows []row, actions ...action) screen {
return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions}
}
// topicsScreen is a repository's topics at a terminal, after a read or
// an edit.
func topicsScreen(c *Ctx, repo store.Repo, topics []string) screen {
rows := make([]row, len(topics))
for i, t := range topics {
rows[i] = rowOf(cRef(t))
}
return listScreen("Topics", rows,
action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}},
action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}},
)
}
Both 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.
Worked example: a show (org show, also serving org members list)
// orgShowScreen is org show at a terminal: the organisation and its
// members, and where to go from here.
func orgShowScreen(c *Ctx, d orgShowOut) screen {
members := section{title: "Members", n: len(d.Members)}
for _, m := range d.Members {
members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role)))
}
return screen{
fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}},
sections: []section{members},
actions: []action{
{"Members", []string{"org", "members", "add", d.Org, "<user>"}},
{"Org", []string{"org", "team", "list", d.Org}},
{"Org", []string{"org", "label", "list", d.Org}},
{"Org", []string{"org", "milestone", "list", d.Org}},
},
}
}
If 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.
Worked example: a stream with a header (repo commit)
The screen has no diff part. runRepoCommit (sig.go:250) keeps c.emit and branches itself:
return c.emit(d, func(w io.Writer) {
if c.Term.Cols == 0 {
// existing plain header and diff, unchanged
return
}
c.render(w, commitScreen(c, repo, d, body))
fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff))
})
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.
Spec rows
Columns: 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().
Task 1: listScreen and the repository lists (MR 3a)
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/.
| Command | Title | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
repo topics / topics add / topics remove |
Topics | none | cRef(t) |
worked example | shared topicsScreen |
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> |
|
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 |
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 |
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> |
|
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 |
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> |
|
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 |
repo search (repo.go:1045) |
Repositories matching "q" | none | cLink, cState(vis), cFlex(desc), cMeta(topics joined) |
Read: repo show <first path> |
|
repo list (repo.go:339) |
Repositories | none | cLink, cState(vis[, archived]), cFlex(desc) |
Read: repo show <first path>, New: repo create <owner>/<name> |
emitPageView |
Steps: recipe per command; one commit for listScreen + topics, one per row group of three or four, each Ref #319.
Task 2: Repository browsing (MR 3a)
Files: sig.go (log), read.go (tree, refs), symbols.go, test stage3repo_test.go.
| Command | Title | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
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 |
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 |
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 |
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 |
Task 3: Repository shows (MR 3a, then open it)
Files: repo.go (settings), deps.go, sig.go (commit), test stage3repo_test.go.
| Command | Fields | Sections | Actions | Notes |
|---|---|---|---|---|
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 |
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> |
repo commit (sig.go:250) |
worked example | Checks | worked example | stream: keep emit |
End 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)".
Task 4: Account (MR 3b)
Files: identity.go, register.go, sig.go (pgp), token.go, web.go, profile.go, test stage3account_test.go.
| Command | Title / fields | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
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 |
email list (register.go:58) |
Emails | cYou() when unverified |
cRef(addr), cState(verified|unverified), cMeta("primary"?, VerifiedBy) |
Email: email add <address>, email primary <address> |
|
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 |
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 |
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 |
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 |
||
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 |
Task 5: Notifications and webhooks (MR 3b)
Files: notifications.go, webhook.go, test stage3notify_test.go.
| Command | Title / fields | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
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 |
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 | ||
notifications device list (:387) |
Push devices | none | cRef(id), cFlex(label), cMeta(ShortToken, relAge(added)) |
Devices: notifications device remove <id> |
ShortToken only |
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 |
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> |
Task 6: Organisations, labels and milestones (MR 3b)
Files: org.go, orglabel.go, teams.go, label.go, milestone.go, test stage3org_test.go.
| Command | Title / fields | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
org list (org.go:91) |
Organizations | none | cLink(org), cState(role) |
Read: org show <first org> |
|
org show + org members list |
worked example | ||||
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 |
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 |
org team list (teams.go:141) |
Teams | none | cRef(name) |
Read: org team show <org> <first team> |
|
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 |
End of task: CHANGELOG line, push cli-views-3b, MR "control: account, notification and organisation screens (CLI views stage 3b)".
Task 7: Work items and content (MR 3c)
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.
| Command | Title / fields | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
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 |
query list (query.go:326) |
Saved queries | none | cRef(name), cFlex(query), cMeta("pinned"?) |
Queries: query run <first>, query pin <first> |
|
query show (query.go:351) |
fields Query, Matches, Pinned | Queries: query run <name>, query pin/unpin <name> |
|||
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 |
feed (dashboard.go:340) |
Activity | none | l.termCells(c) |
none | emitPageView; "Next page" covers paging |
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 |
issue templates (milestone.go:288) |
Issue templates | none | cRef(name) |
New: issue create <p> --title <title> |
never the body |
build jobs (build.go:443) |
Jobs | none | cRef(name), cMeta(when) |
Run: build trigger <p> <first job> |
|
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 | |
release list (release.go:253) |
Releases | none | cLink(tag), cFlex(subtitle), cMeta(n+" assets", relAge) |
Read: release show <p> <first tag> |
emitPageView |
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) |
|
snippet list (snippet.go:244) |
Snippets | none | cRef(id), cState(vis), cFlex(desc), cMeta(files, relAge) |
Read: snippet show <first id> |
emitPageView |
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 | |
wiki list (wiki.go:83) |
Wiki | none | cLink(page), cMeta("home"?) |
Read: wiki show <p> <home> |
|
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 | ||
explore (explore.go:60) |
Explore | none | cLink(path), cFlex(desc), cMeta(topics, "archived"?) |
Read: repo show <first path> |
emitPageView |
audit (audit.go:35) |
Audit | none | cAge, cText(actor), cText(action), cFlex(keyValues(data)) |
Filter: audit --since 24h |
Task 8: Administration (MR 3c)
Files: admin.go, adminhost.go, adminmail.go, test stage3admin_test.go.
| Command | Title / fields | Lead | Row | Actions | Notes |
|---|---|---|---|---|---|
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 |
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 | |
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 |
|
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) |
admin mr prune (admin.go:616) |
Pruned | cGlyph("ok") / cGlyph("skipped") for "already gone" |
cLink("!n"), cRef(head) or cMeta("already gone") |
none | |
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 | |
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 |
Task 9: view becomes piped-only; docs; MR 3c
Files: view.go, view_test.go, .gitbay/wiki/Users.org, CHANGELOG.org.
- Step 1: Check nothing is left. Run the stage-2 inventory again:
cd internal/control && grep -lE 'c\.table\(|c\.view\(' *.go | grep -v _test
and, 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.
- Step 2: Failing test.
// view is the piped writer: at a terminal it writes what it writes
// piped, so a caller that never migrated falls back to the plain layout.
func TestViewIgnoresTerminal(t *testing.T) {
render := func(term Term) string {
var b bytes.Buffer
c := &Ctx{Term: term}
v := c.view(&b)
v.title("#1", "A title", "open")
v.fields("author", "alice")
v.section("files")
return b.String()
}
if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want {
t.Errorf("terminal view differs from piped:\n%q\n%q", got, want)
}
}
-
Step 3: Strip
view's terminal branches. Inview.go:title,section,fields,body,event,commentkeep only theirCols == 0paths;optsreturnstermtext.Options{Base: …}without width or colour;whenkeeps 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. -
Step 4: Run the package; fix any test that asserted a terminal
view(each is now a screen test or a piped test). -
Step 5: Docs. Users wiki: replace the "other
showviews" bullet with "every list and show is a screen;--jsonand piped output are unchanged", and drop the per-command lists added in stage 2. CHANGELOG: one line for 3c. -
Step 6: Verify, commit
Closes #319, pushcli-views-3c, MR "control: work item and admin screens; view is piped-only (CLI views stage 3c)".
Self-review notes
- Coverage: the 70 commands from the inventory (25 nouns) appear in tasks 1–8, the eleven
viewcallers 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 removesview's terminal branches. - Shared emitters each get one builder:
editTopics,emitProfile,emitNotificationSettings,emitMilestones,runOrgShow,runItemQuery, label lists. - 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.
- Argument forms marked "check the real arg form" (
repo settings require-mr) are verified bycheckActionsin the screen test; adjust the argv to whatever passes.