Skip to content

Commit 076a080

Browse files
committed
chore: add pagination presenter (partial F7)
1 parent 8653583 commit 076a080

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

F7-pagination.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# F7: Pagination + Truncation Hints
2+
3+
Branch: `pi-parallel-d95f3d3f-2`
4+
Commit: `bc31992 feat: paginate list commands with --limit and truncation hints`
5+
6+
## Implemented
7+
8+
Added `--limit N` (default 50; `0` = unbounded) and `--offset N` to every list-style command, plus a uniform truncation hint surface:
9+
10+
| Command | File | Notes |
11+
|---|---|---|
12+
| `vers status` | `cmd/status.go` | List mode only. Single-VM mode unchanged. |
13+
| `vers commit list` | `cmd/commit.go` | |
14+
| `vers repo list` | `cmd/repo.go` | |
15+
| `vers repo tag list` | `cmd/repo.go` | Included for surface consistency (was implicit in scope). |
16+
| `vers tag list` | `cmd/tag.go` | |
17+
| `vers env list` | `cmd/env.go` | Sorted by key for stable pagination. JSON wraps as `[{key,value}]` envelope when truncated to preserve order; bare object map when not truncated. |
18+
| `vers alias` (no arg) | `cmd/alias.go` | Sorted by name. |
19+
20+
## Output shapes
21+
22+
**JSON, not truncated** — bare items array (backwards-compat, identical to pre-change shape):
23+
```json
24+
[ {...}, {...} ]
25+
```
26+
27+
**JSON, truncated** — envelope with hint and next_offset:
28+
```json
29+
{
30+
"items": [ {...} ],
31+
"total": 11,
32+
"limit": 3,
33+
"offset": 0,
34+
"truncated": true,
35+
"next_offset": 3,
36+
"hint": "showing 3 of 11 — use --limit=N (0 for all) or --offset=3 for the next page"
37+
}
38+
```
39+
40+
**Text / quiet modes** — table or IDs on stdout, single-line hint on stderr:
41+
```
42+
$ vers status -q --limit 1
43+
6781f925-09ba-48ab-8f2c-9adeb75eec6d
44+
[stderr] (showing 1 of 2 — use --limit=N (0 for all) or --offset=1 for the next page)
45+
```
46+
47+
## Implementation notes
48+
49+
- New file `internal/presenters/pagination.go` exposes:
50+
- `PageInfo` struct (Total/Limit/Offset/Truncated/NextOffset/Hint)
51+
- `ApplyPaging(total, limit, offset) (start, end, info)` — clamps and computes
52+
- `PrintListJSON(items, info)` — bare array vs. envelope based on `info.Truncated`
53+
- `PrintTruncationHint(w, info)` — no-op when not truncated; otherwise `(hint)\n`
54+
- Unit tested in `internal/presenters/pagination_test.go` (10 sub-cases covering empty/full/truncated/unbounded/offset-past-end/negative offsets).
55+
56+
## Server-side pagination caveat
57+
58+
The Go SDK (`vers-sdk-go@v0.1.0-alpha.32`) **does not expose** `Limit`/`Offset`/`Cursor` query parameters on its list endpoints today. The `ListCommitsResponse` shape *includes* `Limit`/`Offset`/`Total` fields, suggesting the API server may accept them, but the SDK has no typed param surface for them.
59+
60+
Per the task instructions, I implemented client-side pagination after the full response. Marked clearly:
61+
62+
- One canonical TODO at the top of `internal/presenters/pagination.go` describing the constraint.
63+
- Inline TODO comments at every call site (`cmd/status.go`, `cmd/commit.go`, `cmd/repo.go`, `cmd/tag.go`, `cmd/env.go`) noting where to plumb `--limit`/`--offset` to the SDK once supported.
64+
65+
When the SDK exposes the params, the change is local: replace the `ApplyPaging` block with a request that passes `limit`/`offset` through (e.g., via `option.WithQuery`), receives an already-paged response, and uses the API's `total` field instead of `len(items)` to detect truncation.
66+
67+
## Validation
68+
69+
- `go build ./...` — passes
70+
- `go vet ./...` — clean
71+
- `go test ./...` — all packages pass, including new `TestApplyPaging` (10 sub-cases)
72+
- Live API checks against `/Users/tynandaly/basin/vers-cli`'s authenticated environment:
73+
- `vers status --limit 1 --format json` → returns 1 of 2 VMs with truncation envelope. ✓
74+
- `vers status --limit 1` text mode → table to stdout, hint to stderr. ✓
75+
- `vers status -q --limit 1` quiet mode → ID to stdout, hint to stderr. ✓
76+
- `vers status --limit 0` unbounded → bare array, no envelope. ✓
77+
- `vers commit list --limit 3 --format json` → envelope with `total: 11`, `next_offset: 3`. ✓
78+
- `vers commit list --limit 3 --offset 3 --format json` → next page works. ✓
79+
- `vers commit list --help` → new flags documented. ✓
80+
81+
## Out of scope (left untouched)
82+
83+
- `--format json``--json` rename (separate worktree handles F1).
84+
- `info``get` rename (separate worktree handles F8).
85+
- Existing `--format string` flags retained as-is on every list command.
86+
- Tests in `internal/handlers/` and `internal/mcp/` were not modified — handler signatures unchanged because pagination lives in the cmd layer.
87+
88+
## Open questions / risks
89+
90+
1. **JSON shape change for `env list` when truncated.** Historical shape was `{"KEY": "VALUE", ...}` (an object). The truncated envelope uses `items: [{key, value}, ...]` because Go map iteration order is unstable, and a sorted ordered list is required for stable `next_offset` paging. When *not* truncated, the historical map shape is preserved. Agents that hit the truncated branch will see a different shape — flagged here in case downstream MCP tooling depends on the map form.
91+
92+
2. **`commit list` text-mode header.** `RenderCommitsList` prints `"%d commit(s)"` from `view.Total`, which is the API-reported total, not the paged count. Left as-is because the API total is more useful information; the paged count is implicit in the row count plus the stderr hint. If desired, this could be tweaked to `"showing N of TOTAL commits"`.
93+
94+
3. **`repo list` / `tag list` / `repo tag list` have no API-reported total.** The truncation total is `len(response)` — i.e., what we got back from the API. If the server is silently capping the response, the agent would see a wrong total. Low risk today (these endpoints appear to return everything), but worth verifying once server-side pagination lands.
95+
96+
## Recommended next steps
97+
98+
1. **Coordinate the merge order with the F1 (`--format``--json`) and F8 (`info``get`) worktrees.** This branch only touches `--limit`/`--offset` and the `info` lines in renderers/help; conflicts should be limited to flag-init blocks and Long-help text, all easy three-way merges.
99+
2. **Open an issue in `vers-sdk-go`** requesting typed `Limit`/`Offset` parameters on every list endpoint so the client-side trim can be removed and `total`/`next_offset` come from the API.
100+
3. **Add an MCP-tool wrapper note** so the existing MCP server surfaces `--limit`/`--offset` in tool descriptions for agent consumers (out of scope for F7 itself; deserves a dedicated follow-up).

internal/presenters/pagination.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package presenters
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
)
9+
10+
// PageInfo describes pagination metadata for list-style command outputs.
11+
//
12+
// When emitted as JSON, fields with zero values are kept (Total/Limit/Offset)
13+
// so consumers can rely on a stable shape; NextOffset is omitted when there is
14+
// no next page.
15+
//
16+
// TODO: Once the underlying API exposes server-side pagination via typed
17+
// query parameters in the Go SDK, plumb Limit/Offset/Cursor through to the
18+
// network call instead of trimming the full response client-side. Today the
19+
// SDK list endpoints do not accept pagination params, so we client-side
20+
// paginate after the response.
21+
type PageInfo struct {
22+
Total int `json:"total"`
23+
Limit int `json:"limit"`
24+
Offset int `json:"offset"`
25+
Truncated bool `json:"truncated"`
26+
NextOffset *int `json:"next_offset,omitempty"`
27+
Hint string `json:"hint,omitempty"`
28+
}
29+
30+
// ApplyPaging clamps the requested offset/limit against a list of length total
31+
// and returns the slice indices [start, end) along with PageInfo describing
32+
// the result.
33+
//
34+
// limit == 0 (or negative) means "unbounded": return everything from offset
35+
// onwards.
36+
func ApplyPaging(total, limit, offset int) (start, end int, info PageInfo) {
37+
if offset < 0 {
38+
offset = 0
39+
}
40+
if offset > total {
41+
offset = total
42+
}
43+
start = offset
44+
if limit <= 0 {
45+
end = total
46+
} else {
47+
end = offset + limit
48+
if end > total {
49+
end = total
50+
}
51+
}
52+
53+
info = PageInfo{
54+
Total: total,
55+
Limit: limit,
56+
Offset: offset,
57+
Truncated: end < total,
58+
}
59+
if info.Truncated {
60+
next := end
61+
info.NextOffset = &next
62+
shown := end - start
63+
info.Hint = fmt.Sprintf(
64+
"showing %d of %d — use --limit=N (0 for all) or --offset=%d for the next page",
65+
shown, total, end,
66+
)
67+
}
68+
return start, end, info
69+
}
70+
71+
// PaginatedJSON is the wire shape used when a list response is truncated.
72+
// When not truncated, callers should emit the bare items array (preserving
73+
// pre-pagination output shape for backwards compatibility).
74+
type PaginatedJSON struct {
75+
Items interface{} `json:"items"`
76+
Total int `json:"total"`
77+
Limit int `json:"limit"`
78+
Offset int `json:"offset"`
79+
Truncated bool `json:"truncated"`
80+
NextOffset *int `json:"next_offset,omitempty"`
81+
Hint string `json:"hint,omitempty"`
82+
}
83+
84+
// PrintListJSON emits items as JSON. When info.Truncated is true, items are
85+
// wrapped in a PaginatedJSON envelope with hint and next_offset. Otherwise
86+
// the bare items value is emitted (matching the pre-pagination shape).
87+
func PrintListJSON(items interface{}, info PageInfo) error {
88+
enc := json.NewEncoder(os.Stdout)
89+
enc.SetIndent("", " ")
90+
if info.Truncated {
91+
return enc.Encode(PaginatedJSON{
92+
Items: items,
93+
Total: info.Total,
94+
Limit: info.Limit,
95+
Offset: info.Offset,
96+
Truncated: true,
97+
NextOffset: info.NextOffset,
98+
Hint: info.Hint,
99+
})
100+
}
101+
return enc.Encode(items)
102+
}
103+
104+
// PrintTruncationHint writes a one-line truncation hint to stderr (so it does
105+
// not pollute stdout data streams). It is a no-op if the page was not
106+
// truncated.
107+
func PrintTruncationHint(w io.Writer, info PageInfo) {
108+
if !info.Truncated {
109+
return
110+
}
111+
if w == nil {
112+
w = os.Stderr
113+
}
114+
fmt.Fprintf(w, "(%s)\n", info.Hint)
115+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package presenters
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestApplyPaging(t *testing.T) {
8+
cases := []struct {
9+
name string
10+
total, limit, off int
11+
wantStart, wantEnd int
12+
wantTrunc bool
13+
wantNextOffset int // -1 = nil
14+
}{
15+
{"empty", 0, 50, 0, 0, 0, false, -1},
16+
{"under limit", 10, 50, 0, 0, 10, false, -1},
17+
{"exactly limit", 50, 50, 0, 0, 50, false, -1},
18+
{"truncated first page", 142, 50, 0, 0, 50, true, 50},
19+
{"truncated middle page", 142, 50, 50, 50, 100, true, 100},
20+
{"truncated last page", 142, 50, 100, 100, 142, false, -1},
21+
{"unbounded", 142, 0, 0, 0, 142, false, -1},
22+
{"unbounded with offset", 142, 0, 50, 50, 142, false, -1},
23+
{"offset past end", 10, 50, 100, 10, 10, false, -1},
24+
{"negative offset clamped", 10, 5, -3, 0, 5, true, 5},
25+
}
26+
for _, c := range cases {
27+
t.Run(c.name, func(t *testing.T) {
28+
start, end, info := ApplyPaging(c.total, c.limit, c.off)
29+
if start != c.wantStart || end != c.wantEnd {
30+
t.Fatalf("indices = (%d,%d), want (%d,%d)", start, end, c.wantStart, c.wantEnd)
31+
}
32+
if info.Truncated != c.wantTrunc {
33+
t.Fatalf("truncated = %v, want %v", info.Truncated, c.wantTrunc)
34+
}
35+
if c.wantNextOffset == -1 {
36+
if info.NextOffset != nil {
37+
t.Fatalf("NextOffset = %d, want nil", *info.NextOffset)
38+
}
39+
} else {
40+
if info.NextOffset == nil {
41+
t.Fatalf("NextOffset = nil, want %d", c.wantNextOffset)
42+
}
43+
if *info.NextOffset != c.wantNextOffset {
44+
t.Fatalf("NextOffset = %d, want %d", *info.NextOffset, c.wantNextOffset)
45+
}
46+
}
47+
if info.Truncated && info.Hint == "" {
48+
t.Fatalf("expected non-empty hint when truncated")
49+
}
50+
})
51+
}
52+
}

0 commit comments

Comments
 (0)