Skip to content

Commit eaf5d24

Browse files
committed
feat(issue-list): auto-compact when table exceeds terminal height
When neither --compact nor --no-compact is specified, issue list now auto-detects whether compact mode should activate based on terminal height. If the estimated non-compact table height (rows × 3 + 4 lines overhead) exceeds process.stdout.rows, compact mode engages automatically. - Change --compact flag from default:false to optional:true (tri-state) - Add shouldAutoCompact(rowCount) utility in formatters/human.ts - Add resolveCompact() to resolve tri-state flag before rendering - Non-TTY (piped) output always gets full (non-compact) rows - --compact forces compact ON, --no-compact forces compact OFF
1 parent c4fc763 commit eaf5d24

File tree

3 files changed

+196
-5
lines changed

3 files changed

+196
-5
lines changed

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
writeJson,
4445
} from "../../lib/formatters/index.js";
@@ -84,7 +85,7 @@ type ListFlags = {
8485
readonly json: boolean;
8586
readonly cursor?: string;
8687
readonly fresh: boolean;
87-
readonly compact: boolean;
88+
readonly compact?: boolean;
8889
};
8990

9091
/** @internal */ export type SortValue = "date" | "new" | "freq" | "user";
@@ -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`);
@@ -1091,7 +1107,9 @@ async function handleResolvedTargets(
10911107
: `Issues from ${validResults.length} projects`;
10921108

10931109
writeListHeader(stdout, title);
1094-
writeIssueTable(stdout, issuesWithOptions, { compact: flags.compact });
1110+
writeIssueTable(stdout, issuesWithOptions, {
1111+
compact: resolveCompact(flags.compact, issuesWithOptions.length),
1112+
});
10951113

10961114
let footerMode: "single" | "multi" | "none" = "none";
10971115
if (isMultiProject) {
@@ -1203,8 +1221,8 @@ export const listCommand = buildListCommand("issue", {
12031221
fresh: FRESH_FLAG,
12041222
compact: {
12051223
kind: "boolean",
1206-
brief: "Single-line rows for compact output",
1207-
default: false,
1224+
brief: "Single-line rows for compact output (auto-detects if omitted)",
1225+
optional: true,
12081226
},
12091227
},
12101228
aliases: {

src/lib/formatters/human.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,34 @@ 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+
/** Fixed line overhead: top border, header, header separator, bottom border. */
400+
const TABLE_LINE_OVERHEAD = 4;
401+
402+
/**
403+
* Determine whether auto-compact should activate based on terminal height.
404+
*
405+
* Returns `true` when the estimated non-compact table height exceeds the
406+
* terminal's row count, meaning compact mode would keep output on-screen.
407+
*
408+
* Returns `false` when terminal height is unknown (non-TTY/piped output)
409+
* to prefer full output for downstream parsing.
410+
*
411+
* @param rowCount - Number of issue rows to render
412+
* @returns Whether compact mode should be used
413+
*/
414+
export function shouldAutoCompact(rowCount: number): boolean {
415+
const termHeight = process.stdout.rows;
416+
if (!termHeight) {
417+
return false;
418+
}
419+
const estimatedHeight =
420+
rowCount * LINES_PER_DEFAULT_ROW + TABLE_LINE_OVERHEAD;
421+
return estimatedHeight > termHeight;
422+
}
423+
396424
/**
397425
* Substatus label for the TREND column's second line.
398426
* Matches Sentry web UI visual indicators.
@@ -575,6 +603,9 @@ function formatTrendCell(issue: SentryIssue, compact = false): string {
575603
* Compact mode (`--compact`): single-line rows for quick scanning. All cells
576604
* collapsed to one line, long titles truncated with "…".
577605
*
606+
* Callers should resolve auto-compact (via {@link shouldAutoCompact}) before
607+
* passing `compact` — this function treats `undefined` as `false`.
608+
*
578609
* @param stdout - Output writer
579610
* @param rows - Issues with formatting options
580611
* @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 separator + bottom border. */
18+
const OVERHEAD = 4;
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)