@@ 177,6 177,47 @@ type listIssuesOutput struct {
// TableTotal is the number of rows in the issues table at this ref, which is
// what makes TableTruncated checkable rather than a bare warning.
TableTotal int `json:"table_total"`
+
+ // Clipped names every table this listing drew from that came back clipped —
+ // one entry per table, in read order, each with the rows read against the
+ // rows that exist and what that specific clip costs this listing.
+ //
+ // It exists because TableTruncated cannot say this: that flag is paired with
+ // Total and Truncated above, so it can only mean the issues/dependencies read
+ // that decides them. This listing also draws label pills and its whole label
+ // filter from a labels table, and every card's lane from custom_statuses, and
+ // a clip in either degrades the listing without moving Total or Truncated by
+ // one. Clipped is where those are named instead. A complete read carries an
+ // empty list, never a null one.
+ Clipped []clippedTableJSON `json:"clipped"`
+}
+
+// clippedTableJSON is one table a listing read that came back clipped: its name,
+// how many rows were read against how many exist, and the one line saying what
+// this listing lost by the rest. It carries beads.ClippedTable across the wire
+// unchanged, under the snake_case vocabulary this surface already uses.
+type clippedTableJSON struct {
+ Table string `json:"table"`
+ Shown int `json:"shown"`
+ Total int `json:"total"`
+ Effect string `json:"effect"`
+}
+
+// clippedTablesJSON carries a projection's per-table clip list into the wire
+// shape, table by table. A nil or empty input answers an empty slice rather than
+// a null one, so "nothing was clipped" and "the field is unset" are never the
+// same JSON value.
+func clippedTablesJSON(clipped []beads.ClippedTable) []clippedTableJSON {
+ out := make([]clippedTableJSON, 0, len(clipped))
+ for _, c := range clipped {
+ out = append(out, clippedTableJSON{
+ Table: c.Table,
+ Shown: c.Shown,
+ Total: c.Total,
+ Effect: c.Effect,
+ })
+ }
+ return out
}
type getIssueInput struct {
@@ 538,6 579,13 @@ func (s *Server) registerBeadsTools() {
"many issues matched before it, and `truncated` says matches were left behind. Separately, " +
"`table_truncated` says the tracker has more than 2000 issues and only the first 2000 were " +
"read, so the counts describe that prefix — `table_total` is the real number.\n\n" +
+ "`clipped` names every *other* table this listing drew from that also came back clipped — " +
+ "labels, custom_statuses — one entry per table with the rows read against the rows that exist " +
+ "and what that specific clip costs this listing: a clipped labels table takes the pills off " +
+ "every card and narrows what the `label` filter can match, and a clipped custom_statuses " +
+ "table can put a card in the wrong lane. `table_truncated`/`table_total` never move for " +
+ "either — they mean only the issues/dependencies read that decides `total` — so `clipped` is " +
+ "the only place those two clips are visible. It is an empty list on a complete read.\n\n" +
"A database that is not a beads tracker says so and points at the generic tools; a database " +
"you may not read is reported as not existing.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, in listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) {
@@ 664,6 712,7 @@ func (s *Server) listIssues(ctx context.Context, in listIssuesInput) (listIssues
Limit: limit,
TableTruncated: data.Truncated,
TableTotal: data.ShownOf,
+ Clipped: clippedTablesJSON(data.Clipped),
}
for _, lane := range data.Lanes {
category, ok := laneCategory(lane.Slug)
@@ 231,6 231,34 @@ func bulkTables(n int) []fakeTable {
// is past the first beads.Max rows any projection here reads.
func bulkTail(n int) string { return "bulk-" + itoa(n-1) }
+// labelsClipCount is the labels table size of labelsClipTables: more than
+// beads.Max (2000), so the labels read alone comes back clipped.
+const labelsClipCount = 2001
+
+// labelsClipTables is a tracker whose issues table stays far under the cap
+// while its labels table alone exceeds it — the case list_issues' long-standing
+// table_truncated/table_total cannot report at all, because that pair means
+// only the issues/dependencies read that decides total and truncated. Here
+// neither of those is short, so the two answer "nothing was clipped" while a
+// card's pills and the label filter are quietly thinner than the tracker really
+// holds; the per-table clip list is the only place that shows up.
+func labelsClipTables() []fakeTable {
+ labels := make([][]string, 0, labelsClipCount)
+ for i := range labelsClipCount {
+ labels = append(labels, []string{"lc-1", "label-" + itoa(i)})
+ }
+ return []fakeTable{
+ beadsTable("issues", issueColumns, [][]string{
+ row(issueColumns, map[string]string{
+ "id": "lc-1", "title": "an issue with more labels than fit", "status": "open",
+ "issue_type": "task", "created_at": "2026-04-01 10:00:00",
+ }),
+ }),
+ beadsTable("dependencies", []string{"issue_id", "depends_on_issue_id", "type"}, nil),
+ beadsTable("labels", []string{"issue_id", "label"}, labels),
+ }
+}
+
func itoa(n int) string {
if n == 0 {
return "0"
@@ 287,6 315,7 @@ func beadsFixtures() []fixture {
),
},
{name: "bulk", visibility: core.VisibilityPublic, session: trackerStore(bulkTables(bulkIssues))},
+ {name: "labelsclip", visibility: core.VisibilityPublic, session: trackerStore(labelsClipTables())},
{name: "memories", visibility: core.VisibilityPublic, session: memoryStore()},
{
// A tracker whose config table holds settings and no memory at all: the
@@ 368,13 397,21 @@ type (
}
listIssuesResult struct {
- Ref string `json:"ref"`
- Issues []issueCardResult `json:"issues"`
- Total int `json:"total"`
- Limit int `json:"limit"`
- Truncated bool `json:"truncated"`
- TableTruncated bool `json:"table_truncated"`
- TableTotal int `json:"table_total"`
+ Ref string `json:"ref"`
+ Issues []issueCardResult `json:"issues"`
+ Total int `json:"total"`
+ Limit int `json:"limit"`
+ Truncated bool `json:"truncated"`
+ TableTruncated bool `json:"table_truncated"`
+ TableTotal int `json:"table_total"`
+ Clipped []clippedTableResult `json:"clipped"`
+ }
+
+ clippedTableResult struct {
+ Table string `json:"table"`
+ Shown int `json:"shown"`
+ Total int `json:"total"`
+ Effect string `json:"effect"`
}
issueResult struct {
@@ 755,6 792,49 @@ func TestListIssuesReportsTheProjectionsOwnClip(t *testing.T) {
assert.Equal(t, 2000, got.Total, "the board was computed over the rows that were read")
assert.Len(t, got.Issues, 10)
assert.True(t, got.Truncated, "the limit clipped the list too, and the two are told apart")
+
+ require.Len(t, got.Clipped, 1, "the issues table is the only one clipped in this fixture")
+ assert.Equal(t, "issues", got.Clipped[0].Table)
+ assert.Equal(t, 2000, got.Clipped[0].Shown)
+ assert.Equal(t, bulkIssues, got.Clipped[0].Total)
+ assert.NotEmpty(t, got.Clipped[0].Effect, "each entry names what the clip costs this listing")
+}
+
+// table_truncated/table_total have only ever meant the issues/dependencies read
+// that decides total and truncated. A clipped labels table moves neither — the
+// issues table here is read whole — so it would be invisible without a place of
+// its own; clipped is that place, named per table with what was read against
+// what exists and what the clip costs this listing.
+func TestListIssuesCarriesThePerTableClipList(t *testing.T) {
+ session := beadsServer(t)
+
+ t.Run("a complete read carries an empty list", func(t *testing.T) {
+ got := listIssues(t, session, args("board"))
+ assert.Empty(t, got.Clipped)
+
+ payload := resultJSON(t, call(t, session, "list_issues", args("board")))
+ assert.Contains(t, payload, `"clipped":[]`,
+ "asserted on the serialised payload: an empty slice, never a null field")
+ })
+
+ t.Run("a clipped labels table shows up with its effect", func(t *testing.T) {
+ got := listIssues(t, session, args("labelsclip"))
+ assert.False(t, got.TableTruncated, "the issues table itself was read whole")
+ assert.Equal(t, 1, got.TableTotal)
+
+ require.Len(t, got.Clipped, 1)
+ clip := got.Clipped[0]
+ assert.Equal(t, "labels", clip.Table)
+ assert.Equal(t, 2000, clip.Shown, "the projection's own per-table cap")
+ assert.Equal(t, labelsClipCount, clip.Total)
+ assert.Contains(t, clip.Effect, "label",
+ "the effect line names what a reader loses, not just that something was clipped")
+
+ payload := resultJSON(t, call(t, session, "list_issues", args("labelsclip")))
+ assert.Contains(t, payload, `"table":"labels"`)
+ assert.Contains(t, payload, `"shown":2000`)
+ assert.Contains(t, payload, `"total":`+itoa(labelsClipCount))
+ })
}
// An omitted ref is the tracker's default branch and the answer names it; a