Skip to content

Commit 4956615

Browse files
feat(issue-list): auto-compact when table exceeds terminal height (#395)
Auto-detect compact mode based on terminal height. When the estimated non-compact table height exceeds `process.stdout.rows`, compact (single-line) rows activate automatically — no flag needed. ### Changes - **`--compact` is now tri-state**: `--compact` forces ON, `--no-compact` forces OFF, omitted → auto-detect via terminal height - **`shouldAutoCompact(rowCount)`** in `src/lib/formatters/human.ts`: estimates table height as `rows × 3 + 4` (2-line content + separator per row, plus border/header overhead). Returns `false` for non-TTY to preserve full output for piped/scripted usage. - **`resolveCompact(flag, rowCount)`** in the list command resolves the tri-state before passing to `writeIssueTable()` ### Behavior | Invocation | Result | |---|---| | `sentry issue list` | Auto: compact if table won't fit terminal | | `sentry issue list --compact` | Always compact | | `sentry issue list --no-compact` | Always full 2-line rows | | `sentry issue list \| jq` | Full output (non-TTY) | ### Tests 7 property-based tests in `test/lib/formatters/auto-compact.property.test.ts` covering non-TTY fallback, edge cases, monotonicity, and determinism. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 332e871 commit 4956615

File tree

4 files changed

+205
-7
lines changed

4 files changed

+205
-7
lines changed

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ List issues in a project
234234
- `-t, --period <value> - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")`
235235
- `-c, --cursor <value> - Pagination cursor for <org>/ or multi-target modes (use "last" to continue)`
236236
- `-f, --fresh - Bypass cache and fetch fresh data`
237-
- `--compact - Single-line rows for compact output`
237+
- `--compact - Single-line rows for compact output (auto-detects if omitted)`
238238
- `--json - Output as JSON`
239239
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
240240

@@ -681,7 +681,7 @@ List issues in a project
681681
- `-t, --period <value> - Time period for issue activity (e.g. 24h, 14d, 90d) - (default: "90d")`
682682
- `-c, --cursor <value> - Pagination cursor for <org>/ or multi-target modes (use "last" to continue)`
683683
- `-f, --fresh - Bypass cache and fetch fresh data`
684-
- `--compact - Single-line rows for compact output`
684+
- `--compact - Single-line rows for compact output (auto-detects if omitted)`
685685
- `--json - Output as JSON`
686686
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
687687

src/commands/issue/list.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import {
4040
type IssueTableRow,
4141
muted,
42+
shouldAutoCompact,
4243
writeIssueTable,
4344
writeJsonList,
4445
} from "../../lib/formatters/index.js";
@@ -83,7 +84,7 @@ type ListFlags = {
8384
readonly json: boolean;
8485
readonly cursor?: string;
8586
readonly fresh: boolean;
86-
readonly compact: boolean;
87+
readonly compact?: boolean;
8788
readonly fields?: string[];
8889
};
8990

@@ -100,6 +101,19 @@ const USAGE_HINT = "sentry issue list <org>/<project>";
100101
*/
101102
const MAX_LIMIT = 1000;
102103

104+
/**
105+
* Resolve the effective compact mode from the flag tri-state and issue count.
106+
*
107+
* - `true` / `false` — explicit user override, returned as-is
108+
* - `undefined` — auto-detect based on terminal height vs estimated table height
109+
*/
110+
function resolveCompact(flag: boolean | undefined, rowCount: number): boolean {
111+
if (flag !== undefined) {
112+
return flag;
113+
}
114+
return shouldAutoCompact(rowCount);
115+
}
116+
103117
function parseSort(value: string): SortValue {
104118
if (!VALID_SORT_VALUES.includes(value as SortValue)) {
105119
throw new Error(
@@ -829,7 +843,9 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise<void> {
829843
isMultiProject: true,
830844
},
831845
}));
832-
writeIssueTable(stdout, issuesWithOpts, { compact: flags.compact });
846+
writeIssueTable(stdout, issuesWithOpts, {
847+
compact: resolveCompact(flags.compact, issuesWithOpts.length),
848+
});
833849

834850
if (hasMore) {
835851
stdout.write(`\nShowing ${issues.length} issues (more available)\n`);
@@ -1092,7 +1108,9 @@ async function handleResolvedTargets(
10921108
: `Issues from ${validResults.length} projects`;
10931109

10941110
writeListHeader(stdout, title);
1095-
writeIssueTable(stdout, issuesWithOptions, { compact: flags.compact });
1111+
writeIssueTable(stdout, issuesWithOptions, {
1112+
compact: resolveCompact(flags.compact, issuesWithOptions.length),
1113+
});
10961114

10971115
let footerMode: "single" | "multi" | "none" = "none";
10981116
if (isMultiProject) {
@@ -1204,8 +1222,8 @@ export const listCommand = buildListCommand("issue", {
12041222
fresh: FRESH_FLAG,
12051223
compact: {
12061224
kind: "boolean",
1207-
brief: "Single-line rows for compact output",
1208-
default: false,
1225+
brief: "Single-line rows for compact output (auto-detects if omitted)",
1226+
optional: true,
12091227
},
12101228
},
12111229
aliases: {

src/lib/formatters/human.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,41 @@ function computeAliasShorthand(shortId: string, projectAlias?: string): string {
393393
/** Minimum terminal width to show the TREND sparkline column. */
394394
const TREND_MIN_TERM_WIDTH = 100;
395395

396+
/** Lines per issue row in non-compact mode (2-line content + separator). */
397+
const LINES_PER_DEFAULT_ROW = 3;
398+
399+
/**
400+
* Fixed line overhead for the rendered table.
401+
*
402+
* Top border (1) + header row (1) + header separator (1) + bottom border (1) = 4,
403+
* minus 1 because the last data row has no trailing separator (row separators
404+
* are drawn between data rows only: `r > 0 && r < allRows.length - 1`).
405+
* Net overhead = 3.
406+
*/
407+
const TABLE_LINE_OVERHEAD = 3;
408+
409+
/**
410+
* Determine whether auto-compact should activate based on terminal height.
411+
*
412+
* Returns `true` when the estimated non-compact table height exceeds the
413+
* terminal's row count, meaning compact mode would keep output on-screen.
414+
*
415+
* Returns `false` when terminal height is unknown (non-TTY/piped output)
416+
* to prefer full output for downstream parsing.
417+
*
418+
* @param rowCount - Number of issue rows to render
419+
* @returns Whether compact mode should be used
420+
*/
421+
export function shouldAutoCompact(rowCount: number): boolean {
422+
const termHeight = process.stdout.rows;
423+
if (!termHeight) {
424+
return false;
425+
}
426+
const estimatedHeight =
427+
rowCount * LINES_PER_DEFAULT_ROW + TABLE_LINE_OVERHEAD;
428+
return estimatedHeight > termHeight;
429+
}
430+
396431
/**
397432
* Substatus label for the TREND column's second line.
398433
* Matches Sentry web UI visual indicators.
@@ -575,6 +610,9 @@ function formatTrendCell(issue: SentryIssue, compact = false): string {
575610
* Compact mode (`--compact`): single-line rows for quick scanning. All cells
576611
* collapsed to one line, long titles truncated with "…".
577612
*
613+
* Callers should resolve auto-compact (via {@link shouldAutoCompact}) before
614+
* passing `compact` — this function treats `undefined` as `false`.
615+
*
578616
* @param stdout - Output writer
579617
* @param rows - Issues with formatting options
580618
* @param options - Display options
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Property-Based Tests for shouldAutoCompact
3+
*
4+
* Verifies invariants of the terminal-height-based auto-compact heuristic.
5+
* Since shouldAutoCompact reads process.stdout.rows, tests save/restore the
6+
* property around each assertion.
7+
*/
8+
9+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10+
import { assert as fcAssert, integer, nat, property, tuple } from "fast-check";
11+
import { shouldAutoCompact } from "../../../src/lib/formatters/human.js";
12+
import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js";
13+
14+
/** Lines per non-compact row (2 content + 1 separator). */
15+
const LINES_PER_ROW = 3;
16+
17+
/** Fixed overhead: top border + header + header sep + bottom border − 1 (no trailing row sep). */
18+
const OVERHEAD = 3;
19+
20+
/** Save original process.stdout.rows so we can restore it after each test. */
21+
let originalRows: number | undefined;
22+
23+
beforeEach(() => {
24+
originalRows = process.stdout.rows;
25+
});
26+
27+
afterEach(() => {
28+
Object.defineProperty(process.stdout, "rows", {
29+
value: originalRows,
30+
writable: true,
31+
configurable: true,
32+
});
33+
});
34+
35+
/** Set process.stdout.rows for testing. */
36+
function setTermHeight(rows: number | undefined): void {
37+
Object.defineProperty(process.stdout, "rows", {
38+
value: rows,
39+
writable: true,
40+
configurable: true,
41+
});
42+
}
43+
44+
describe("property: shouldAutoCompact", () => {
45+
test("returns false when terminal height is undefined (non-TTY)", () => {
46+
fcAssert(
47+
property(nat(500), (rowCount) => {
48+
setTermHeight(undefined);
49+
expect(shouldAutoCompact(rowCount)).toBe(false);
50+
}),
51+
{ numRuns: DEFAULT_NUM_RUNS }
52+
);
53+
});
54+
55+
test("returns false when terminal height is 0", () => {
56+
fcAssert(
57+
property(nat(500), (rowCount) => {
58+
setTermHeight(0);
59+
expect(shouldAutoCompact(rowCount)).toBe(false);
60+
}),
61+
{ numRuns: DEFAULT_NUM_RUNS }
62+
);
63+
});
64+
65+
test("zero rows only triggers compact if overhead alone exceeds terminal", () => {
66+
fcAssert(
67+
property(integer({ min: 1, max: 500 }), (termHeight) => {
68+
setTermHeight(termHeight);
69+
// With 0 rows, estimated = OVERHEAD. Compact iff OVERHEAD > termHeight.
70+
expect(shouldAutoCompact(0)).toBe(OVERHEAD > termHeight);
71+
}),
72+
{ numRuns: DEFAULT_NUM_RUNS }
73+
);
74+
});
75+
76+
test("returns true when estimated height exceeds terminal height", () => {
77+
fcAssert(
78+
property(
79+
tuple(integer({ min: 1, max: 200 }), integer({ min: 5, max: 500 })),
80+
([rowCount, termHeight]) => {
81+
const estimated = rowCount * LINES_PER_ROW + OVERHEAD;
82+
if (estimated > termHeight) {
83+
setTermHeight(termHeight);
84+
expect(shouldAutoCompact(rowCount)).toBe(true);
85+
}
86+
}
87+
),
88+
{ numRuns: DEFAULT_NUM_RUNS }
89+
);
90+
});
91+
92+
test("returns false when estimated height fits within terminal", () => {
93+
fcAssert(
94+
property(
95+
tuple(integer({ min: 1, max: 200 }), integer({ min: 5, max: 2000 })),
96+
([rowCount, termHeight]) => {
97+
const estimated = rowCount * LINES_PER_ROW + OVERHEAD;
98+
if (estimated <= termHeight) {
99+
setTermHeight(termHeight);
100+
expect(shouldAutoCompact(rowCount)).toBe(false);
101+
}
102+
}
103+
),
104+
{ numRuns: DEFAULT_NUM_RUNS }
105+
);
106+
});
107+
108+
test("is monotonic: more rows never decreases compactness", () => {
109+
fcAssert(
110+
property(
111+
tuple(nat(100), nat(100), integer({ min: 10, max: 200 })),
112+
([a, b, termHeight]) => {
113+
setTermHeight(termHeight);
114+
const smaller = Math.min(a, b);
115+
const larger = Math.max(a, b);
116+
const compactSmall = shouldAutoCompact(smaller);
117+
const compactLarge = shouldAutoCompact(larger);
118+
// If fewer rows triggers compact, more rows must also trigger compact
119+
if (compactSmall) {
120+
expect(compactLarge).toBe(true);
121+
}
122+
}
123+
),
124+
{ numRuns: DEFAULT_NUM_RUNS }
125+
);
126+
});
127+
128+
test("is deterministic: same inputs produce same output", () => {
129+
fcAssert(
130+
property(
131+
tuple(nat(200), integer({ min: 1, max: 500 })),
132+
([rowCount, termHeight]) => {
133+
setTermHeight(termHeight);
134+
const result1 = shouldAutoCompact(rowCount);
135+
const result2 = shouldAutoCompact(rowCount);
136+
expect(result1).toBe(result2);
137+
}
138+
),
139+
{ numRuns: DEFAULT_NUM_RUNS }
140+
);
141+
});
142+
});

0 commit comments

Comments
 (0)