Skip to content

feat(formatters): render all terminal output as markdown#297

Merged
BYK merged 53 commits intomainfrom
feat/markdown-terminal-rendering
Mar 1, 2026
Merged

feat(formatters): render all terminal output as markdown#297
BYK merged 53 commits intomainfrom
feat/markdown-terminal-rendering

Conversation

@BYK
Copy link
Member

@BYK BYK commented Feb 26, 2026

Summary

  • Replace all ad-hoc ANSI/chalk formatting with a markdown-first pipeline: formatters build CommonMark strings internally, which are rendered through a custom marked-terminal renderer in TTY mode or emitted as raw markdown in non-TTY/piped mode
  • Plain output mode: auto-detected via process.stdout.isTTY; overridable with SENTRY_PLAIN_OUTPUT or NO_COLOR. TTY → styled Unicode tables + ANSI colors. Non-TTY → raw CommonMark (great for piping, CI, AI agents)
  • Issue list SHORT IDs are markdown links rendered as OSC 8 terminal hyperlinks (clickable in iTerm2, VS Code, Windows Terminal, etc.) with alias characters bold-highlighted (e.g. CLI-WEBSITE-4)
  • Seer output (root cause analysis, fix plans) renders fenced code blocks with syntax highlighting and structured heading hierarchy
image

Key changes

  • Add src/lib/formatters/markdown.tsrenderMarkdown(), renderInlineMarkdown(), isPlainOutput(), escapeMarkdownCell(), mdRow(), mdKvTable(), mdTableHeader(), divider() helpers
  • All detail formatters (formatIssueDetails, formatEventDetails, formatOrgDetails, formatProjectDetails, formatLogDetails, formatTraceSummary, formatRootCauseList, formatSolution) return rendered string instead of string[]
  • writeTable() processes cell values through renderInlineMarkdown() so **bold** and [text](url) in cells render as styled/clickable text; markdown links become OSC 8 hyperlinks
  • formatShortId() returns markdown bold (**text**) instead of ANSI for alias highlighting — composable with link syntax
  • Streaming row formatters (formatLogRow, formatTraceRow) are dual-mode: padded ANSI text in TTY, raw markdown table rows in plain mode
  • Fix SDK name/exception value/org name escaping so special characters (_, `, |, \) don't break markdown rendering

@github-actions
Copy link
Contributor

github-actions bot commented Feb 26, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (formatters) Render all terminal output as markdown by BYK in #297
  • (issue-list) Global limit with fair distribution, compound cursor, and richer progress by BYK in #306

Bug Fixes 🐛

Api

  • Use limit param for issues endpoint page size by BYK in #309
  • Auto-correct ':' to '=' in --field values with a warning by BYK in #302

Other

  • (ci) Generate JUnit XML to silence codecov-action warnings by BYK in #300
  • (nightly) Push to GHCR from artifacts dir so layer titles are bare filenames by BYK in #301
  • (test) Handle 0/-0 in getComparator anti-symmetry property test by BYK in #308

Internal Changes 🔧

  • (api) Wire listIssuesPaginated through @sentry/api SDK for type safety by BYK in #310

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 26, 2026

Codecov Results 📊

2268 passed | Total: 2268 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests 📈 +158
Passed Tests 📈 +158
Failed Tests
Skipped Tests

All tests are passing successfully.

✅ Patch coverage is 91.18%. Project has 3235 uncovered lines.
✅ Project coverage is 80.05%. Comparing base (base) to head (head).

Files with missing lines (20)
File Patch % Lines
list.ts 27.80% ⚠️ 174 Missing
plan.ts 19.47% ⚠️ 153 Missing
list.ts 87.19% ⚠️ 92 Missing
view.ts 26.67% ⚠️ 88 Missing
view.ts 41.50% ⚠️ 86 Missing
list.ts 24.47% ⚠️ 71 Missing
view.ts 68.04% ⚠️ 70 Missing
list.ts 85.68% ⚠️ 59 Missing
explain.ts 33.73% ⚠️ 55 Missing
markdown.ts 88.75% ⚠️ 36 Missing
human.ts 96.14% ⚠️ 31 Missing
view.ts 88.24% ⚠️ 26 Missing
table.ts 61.54% ⚠️ 15 Missing
trace.ts 92.74% ⚠️ 9 Missing
view.ts 94.93% ⚠️ 7 Missing
log.ts 96.79% ⚠️ 5 Missing
colors.ts 96.55% ⚠️ 2 Missing
output.ts 89.47% ⚠️ 2 Missing
seer.ts 98.46% ⚠️ 2 Missing
list.ts 98.99% ⚠️ 1 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    77.05%    80.05%       +3%
==========================================
  Files          117       120        +3
  Lines        15576     16218      +642
  Branches         0         0         —
==========================================
+ Hits         12002     12983      +981
- Misses        3574      3235      -339
- Partials         0         0         —

Generated by Codecov Action

@BYK
Copy link
Member Author

BYK commented Feb 26, 2026

Re: merging human.ts and markdown.ts (comment)

Good point — they do overlap in responsibility. markdown.ts is the rendering engine layer (marked-terminal config, renderMarkdown(), isPlainOutput(), escapeMarkdownCell(), mdTableHeader(), divider()). human.ts is the content/layout layer that builds entity-specific markdown strings (issues, events, orgs, projects) and pipes them through the engine.

Merging them would create a ~1600-line file. I think the current split works well: markdown.ts is entity-agnostic utilities, human.ts is Sentry-entity-specific formatting. Similar to how colors.ts defines color functions but human.ts decides which fields get which colors.

That said, if you feel strongly about it I can merge — just want to flag the file size concern.

@BYK BYK marked this pull request as ready for review February 26, 2026 14:45
@BYK BYK force-pushed the feat/markdown-terminal-rendering branch from 1ad7523 to 2ff821a Compare February 26, 2026 16:12
@BYK BYK force-pushed the feat/markdown-terminal-rendering branch from f46a348 to 6afd84b Compare February 28, 2026 17:22
…Table/mdRow

Migrate 11 manual markdown table locations to use shared helpers:
- 6 KV tables in human.ts + 1 in trace.ts now use mdKvTable()
- 3 data table rows in human.ts, trace.ts, log.ts now use mdRow()
- 1 data table header in human.ts now uses mdTableHeader()

Simplify mdKvTable() escaping: only replace pipes and newlines
(structural safety). Content escaping is the caller's responsibility,
which allows values with intentional markdown (colorTag, safeCodeSpan,
backtick spans) to pass through unmangled.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

… output

Add StreamingTable class to text-table.ts that renders incrementally:
header (top border + column names + separator), row-by-row with side
borders, and footer (bottom border) on stream end.

Log streaming (--follow) now uses bordered tables in TTY mode:
- createLogStreamingTable() factory with pre-configured column hints
- SIGINT handler prints bottom border before exit
- Plain mode (non-TTY) still emits raw markdown rows for pipe safety

Trace streaming gets createTraceStreamingTable() factory (not yet wired
to command — traces don't have --follow yet, but the factory is ready).

Extract buildLogRowCells() and export buildTraceRowCells() so both
streaming (StreamingTable.row) and batch (formatLogTable/formatTraceTable)
paths share the same cell-building logic.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

…ch table double-render

- Escape firstReleaseVersion/lastReleaseVersion in issue firstSeen/lastSeen
  cells via escapeMarkdownCell() to prevent pipe/backslash chars breaking
  markdown table structure
- Escape rootTransaction in formatTraceSummary for the same reason
- Fix formatLogTable and formatTraceTable which were passing mdRow output
  (already ANSI-rendered in TTY mode) through renderMarkdown() a second time,
  mangling the table. Both functions now use renderTextTable() directly in
  TTY mode and mdRow/mdTableHeader in plain mode (same pattern as writeTable)
- Remove stale @param isMultiProject from writeListHeader JSDoc
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reorder the two .replace() calls in the test helper so that color
tags (<red>, </red>) are removed first. This prevents CodeQL's
'incomplete multi-character sanitization' alert where ANSI code
removal could theoretically join fragments into tag-like sequences.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

BYK added 2 commits February 28, 2026 22:00
… ID column

Replace markdown **bold** syntax with chalk boldUnderline() for alias
highlighting in formatShortId and formatShortIdWithAlias. The markdown
approach only produced bold text; the original used bold+underline via
chalk to make alias indicators visually distinct. ANSI codes survive
the table rendering pipeline and display correctly in TTY mode.
…ANSI

Use the existing colorTag system with a new 'bu' (bold+underline)
tag instead of direct chalk calls. This keeps alias indicators as
markdown-compatible content that strips cleanly in plain mode and
renders through the custom markdown renderer in TTY mode.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

… code

Escape user-facing values (org.name, project.name, assignee name,
release versions) with escapeMarkdownInline() before passing to
mdKvTable, as its contract requires callers to handle content
escaping. Remove unused createTraceStreamingTable, TRACE_HINT_ROWS,
and StreamingTableOptions import — trace list has no streaming mode.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Apply renderInlineMarkdown() to each cell in the log streaming path
so that color tags (<red>ERROR</red>) and code spans are rendered to
ANSI instead of appearing as literal text.

Hoist the COLOR_TAG_RE regex to module scope so stripColorTags()
doesn't recompile it on every invocation.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Remove escapeMarkdownInline from the TITLE column value function in
writeIssueTable — column values should return raw text, not pre-escaped
markdown. Instead, expand escapeMarkdownCell to also escape inline
emphasis chars (*, _, `, [, ]) so the plain-mode path in
buildMarkdownTable produces safe CommonMark without double-escaping
backslashes.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Plain-mode table escaping destroys intentional markdown links
    • Moved user data escaping to column definitions (TITLE, NAME, URL fields) and removed blanket escaping from buildMarkdownTable, preserving intentional markdown links in SHORT ID column.

Create PR

Or push these changes by commenting:

@cursor push 137e130db6
Preview (137e130db6)
diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts
--- a/src/commands/org/list.ts
+++ b/src/commands/org/list.ts
@@ -10,6 +10,7 @@
 import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js";
 import { getAllOrgRegions } from "../../lib/db/regions.js";
 import { writeFooter, writeJson } from "../../lib/formatters/index.js";
+import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import { buildListLimitFlag, LIST_JSON_FLAG } from "../../lib/list-command.js";
 
@@ -108,7 +109,7 @@
       ...(showRegion
         ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }]
         : []),
-      { header: "NAME", value: (r) => r.name },
+      { header: "NAME", value: (r) => escapeMarkdownCell(r.name) },
     ];
 
     writeTable(stdout, rows, columns);

diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts
--- a/src/commands/project/list.ts
+++ b/src/commands/project/list.ts
@@ -32,6 +32,7 @@
 } from "../../lib/db/pagination.js";
 import { AuthError, ContextError } from "../../lib/errors.js";
 import { writeFooter, writeJson } from "../../lib/formatters/index.js";
+import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import {
   buildListCommand,
@@ -229,7 +230,7 @@
 const PROJECT_COLUMNS: Column<ProjectWithOrg>[] = [
   { header: "ORG", value: (p) => p.orgSlug || "" },
   { header: "PROJECT", value: (p) => p.slug },
-  { header: "NAME", value: (p) => p.name },
+  { header: "NAME", value: (p) => escapeMarkdownCell(p.name) },
   { header: "PLATFORM", value: (p) => p.platform || "" },
 ];
 

diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts
--- a/src/commands/repo/list.ts
+++ b/src/commands/repo/list.ts
@@ -14,6 +14,7 @@
   listRepositories,
   listRepositoriesPaginated,
 } from "../../lib/api-client.js";
+import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import {
   buildOrgListCommand,
@@ -31,10 +32,10 @@
 /** Column definitions for the repository table. */
 const REPO_COLUMNS: Column<RepositoryWithOrg>[] = [
   { header: "ORG", value: (r) => r.orgSlug || "" },
-  { header: "NAME", value: (r) => r.name },
+  { header: "NAME", value: (r) => escapeMarkdownCell(r.name) },
   { header: "PROVIDER", value: (r) => r.provider.name },
   { header: "STATUS", value: (r) => r.status },
-  { header: "URL", value: (r) => r.url || "" },
+  { header: "URL", value: (r) => escapeMarkdownCell(r.url || "") },
 ];
 
 /** Shared config that plugs into the org-list framework. */

diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts
--- a/src/commands/team/list.ts
+++ b/src/commands/team/list.ts
@@ -15,6 +15,7 @@
   listTeams,
   listTeamsPaginated,
 } from "../../lib/api-client.js";
+import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
 import { type Column, writeTable } from "../../lib/formatters/table.js";
 import {
   buildOrgListCommand,
@@ -33,7 +34,7 @@
 const TEAM_COLUMNS: Column<TeamWithOrg>[] = [
   { header: "ORG", value: (t) => t.orgSlug || "" },
   { header: "SLUG", value: (t) => t.slug },
-  { header: "NAME", value: (t) => t.name },
+  { header: "NAME", value: (t) => escapeMarkdownCell(t.name) },
   {
     header: "MEMBERS",
     value: (t) => String(t.memberCount ?? ""),

diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts
--- a/src/lib/formatters/human.ts
+++ b/src/lib/formatters/human.ts
@@ -477,7 +477,7 @@
     },
     {
       header: "TITLE",
-      value: ({ issue }) => issue.title,
+      value: ({ issue }) => escapeMarkdownCell(issue.title),
     }
   );
 

diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts
--- a/src/lib/formatters/table.ts
+++ b/src/lib/formatters/table.ts
@@ -55,7 +55,7 @@
   const rows = items
     .map(
       (item) =>
-        `| ${columns.map((c) => escapeMarkdownCell(stripColorTags(c.value(item)))).join(" | ")} |`
+        `| ${columns.map((c) => stripColorTags(c.value(item))).join(" | ")} |`
     )
     .join("\n");
   return `${header}\n${separator}\n${rows}`;
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

BYK added 2 commits February 28, 2026 23:28
Revert [, ], and backtick escaping from escapeMarkdownCell — these
broke intentional markdown links ([SHORT-ID](permalink)) in the
issue table's SHORT ID column. Keep only _ and * escaping (for
issue titles) alongside the original structural escapes (|, \, <, >).
Column values are raw text; escapeMarkdownCell is the single
escaping pass in buildMarkdownTable.
Move escapeMarkdownCell responsibility from buildMarkdownTable to
individual column value functions. This preserves intentional markdown
(links in SHORT ID, code spans in SLUG) while escaping user data
(NAME, TITLE, URL) at the source. Columns that produce markdown-safe
content (slugs, enums, counts) don't need escaping.
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

export function formatRelativeTime(dateString: string | undefined): string {
if (!dateString) {
return muted("—").padEnd(10);
return muted("—");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ANSI codes leak into plain/piped output

High Severity

formatRelativeTime returns muted("—") for undefined dates, which applies ANSI escape codes via chalk. In the new markdown-first architecture, all cell values pass through stripColorTags() in plain mode, but that function only strips custom HTML-like <red>…</red> color tags — not raw ANSI escape sequences. So the ANSI codes from muted() leak into piped/CI/non-TTY output. Every other formatter in this PR uses colorTag() for colors, which stripColorTags() can handle. This call needs to use colorTag("muted", "—") instead.

Fix in Cursor Fix in Web

},
{
header: "TITLE",
value: ({ issue }) => escapeMarkdownCell(issue.title),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue titles with underscores render as markdown emphasis

Medium Severity

The TITLE column uses escapeMarkdownCell(issue.title) which does not escape _ or *. Since cell values pass through renderInlineMarkdown in TTY mode, issue titles containing paired underscores (common in Python — __init__, __main__, __str__) are rendered as bold/italic emphasis instead of literal text. Using escapeMarkdownInline or adding emphasis-character escaping to this cell value would prevent misrendered titles.

Fix in Cursor Fix in Web

…r titles

Replace muted("—") with colorTag("muted", "—") in formatRelativeTime
so stripColorTags handles it in plain mode — raw ANSI from chalk leaks
through since stripColorTags only strips <tag> pairs.

Use escapeMarkdownInline for issue titles instead of escapeMarkdownCell
to escape _, *, and backticks — prevents __init__ rendering as emphasis
in TTY mode.
Comment on lines +478 to +481
{
header: "TITLE",
value: ({ issue }) => escapeMarkdownInline(issue.title),
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The TITLE column uses escapeMarkdownInline, which doesn't escape pipe characters (|). This corrupts the markdown table layout when an issue title contains a pipe in plain output mode.
Severity: MEDIUM

Suggested Fix

In src/lib/formatters/human.ts, change the TITLE column definition to use escapeMarkdownCell instead of escapeMarkdownInline. This will correctly escape pipe characters and other structural table characters, preventing layout corruption.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/lib/formatters/human.ts#L478-L481

Potential issue: In `src/lib/formatters/human.ts`, the `TITLE` column's value is
processed by `escapeMarkdownInline`. This function does not escape pipe characters
(`|`). When the CLI is run in plain output mode (non-TTY), `buildMarkdownTable`
constructs a markdown table by joining cell values with ` | `. If an issue title
contains a pipe, it will be treated as a column delimiter, corrupting the table's
structure by adding an extra, unintended column. This is a realistic scenario as error
messages often contain pipes.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

formatLogTable and formatTraceTable already return strings ending
with a newline from renderTextTable. The template literal wrapper
added a second one, creating two blank lines before the footer.
@BYK BYK merged commit efb59ba into main Mar 1, 2026
20 checks passed
@BYK BYK deleted the feat/markdown-terminal-rendering branch March 1, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant