Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ View a dashboard
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`
- `-r, --refresh <value> - Auto-refresh interval in seconds (default: 60, min: 10)`
- `-t, --period <value> - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"`
- `--renderer <value> - Graphics renderer (defaults to auto; falls back to auto when unavailable) - (default: "auto")`

**Examples:**

Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/dashboard/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { logger } from "../../lib/logger.js";
import { withProgress } from "../../lib/polling.js";
import { resolveOrgRegion } from "../../lib/region.js";
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
import type { GraphicsRendererPreference } from "../../lib/sixel.js";
import {
formatTimeRangeFlag,
PERIOD_BRIEF,
Expand Down Expand Up @@ -52,6 +53,7 @@ type ViewFlags = {
readonly fresh: boolean;
readonly refresh?: number;
readonly period?: TimeRange;
readonly renderer: GraphicsRendererPreference;
readonly json: boolean;
readonly fields?: string[];
};
Expand Down Expand Up @@ -107,7 +109,11 @@ function buildViewData(
},
widgetResults: Map<number, WidgetDataResult>,
widgets: DashboardWidget[],
opts: { period: string; url: string }
opts: {
period: string;
rendererPreference: GraphicsRendererPreference;
url: string;
}
): DashboardViewData {
return {
id: dashboard.id,
Expand All @@ -117,6 +123,7 @@ function buildViewData(
url: opts.url,
dateCreated: dashboard.dateCreated,
environment: dashboard.environment,
rendererPreference: opts.rendererPreference,
widgets: widgets.map((w, i) => ({
title: w.title,
displayType: w.displayType,
Expand Down Expand Up @@ -166,12 +173,14 @@ export const viewCommand = buildCommand({
" sentry dashboard view my-org/my-project 12345\n" +
" sentry dashboard view 12345 --json\n" +
" sentry dashboard view 12345 --period 7d\n" +
" sentry dashboard view 12345 --renderer sixel\n" +
" sentry dashboard view 12345 -r\n" +
" sentry dashboard view 12345 -r 30\n" +
" sentry dashboard view 12345 --web",
},
output: {
human: createDashboardViewRenderer,
jsonExclude: ["rendererPreference"],
},
parameters: {
positional: {
Expand Down Expand Up @@ -202,6 +211,13 @@ export const viewCommand = buildCommand({
brief: PERIOD_BRIEF,
optional: true,
},
renderer: {
kind: "enum",
values: ["auto", "kitty", "sixel"],
brief:
"Graphics renderer (defaults to auto; falls back to auto when unavailable)",
default: "auto",
},
},
aliases: {
...FRESH_ALIASES,
Expand Down Expand Up @@ -285,6 +301,7 @@ export const viewCommand = buildCommand({
// Build output data before clearing so clear→render is instantaneous
const viewData = buildViewData(dashboard, widgetData, widgets, {
period: formatTimeRangeFlag(timeRange),
rendererPreference: flags.renderer,
url,
});

Expand Down Expand Up @@ -316,6 +333,7 @@ export const viewCommand = buildCommand({
yield new CommandOutput(
buildViewData(dashboard, widgetData, widgets, {
period: formatTimeRangeFlag(timeRange),
rendererPreference: flags.renderer,
url,
})
);
Expand Down
113 changes: 106 additions & 7 deletions packages/cli/src/lib/formatters/cozette-font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,95 @@ const FIRST_ASCII_CODE_POINT = 32;
const LAST_ASCII_CODE_POINT = 126;
const FULL_BLOCK_CODE_POINT = 0x25_88;
const BLACK_SQUARE_CODE_POINT = 0x25_a0;
const UPPER_HALF_BLOCK_CODE_POINT = 0x25_80;
const LOWER_HALF_BLOCK_CODE_POINT = 0x25_84;
const HORIZONTAL_RULE_CODE_POINT = 0x25_00;
const VERTICAL_RULE_CODE_POINT = 0x25_02;
const LOWER_LEFT_CORNER_CODE_POINT = 0x25_14;
const RIGHT_TEE_CODE_POINT = 0x25_24;
const DOWN_TEE_CODE_POINT = 0x25_2c;
const ELLIPSIS_CODE_POINT = 0x20_26;
const LIGHT_SHADE_CODE_POINT = 0x25_91;
const MEDIUM_SHADE_CODE_POINT = 0x25_92;
const DARK_SHADE_CODE_POINT = 0x25_93;
const LOWER_ONE_EIGHTH_BLOCK_CODE_POINT = 0x25_81;
const LOWER_TWO_EIGHTHS_BLOCK_CODE_POINT = 0x25_82;
const LOWER_THREE_EIGHTHS_BLOCK_CODE_POINT = 0x25_83;
const LOWER_FIVE_EIGHTHS_BLOCK_CODE_POINT = 0x25_85;
const LOWER_SIX_EIGHTHS_BLOCK_CODE_POINT = 0x25_86;
const LOWER_SEVEN_EIGHTHS_BLOCK_CODE_POINT = 0x25_87;
const FULL_BLOCK_INDEX = LAST_ASCII_CODE_POINT - FIRST_ASCII_CODE_POINT + 1;
const BLACK_SQUARE_INDEX = FULL_BLOCK_INDEX + 1;
const QUESTION_MARK_INDEX = 0x3f - FIRST_ASCII_CODE_POINT;

/** Visible fallback for a Unicode glyph outside the embedded Cozette subset. */
const UNKNOWN_GLYPH = new Uint8Array([
63, 33, 45, 45, 45, 45, 45, 45, 45, 45, 33, 63, 0,
/** Dashboard's compact number and table renderers use these block glyphs. */
const UPPER_HALF_BLOCK_GLYPH = new Uint8Array([
63, 63, 63, 63, 63, 63, 0, 0, 0, 0, 0, 0, 0,
]);
const LOWER_HALF_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 0, 63, 63, 63, 63, 63, 63, 63,
]);
const HORIZONTAL_RULE_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 0, 0, 0,
]);
const VERTICAL_RULE_GLYPH = new Uint8Array([
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
]);
const LOWER_LEFT_CORNER_GLYPH = new Uint8Array([
32, 32, 32, 32, 32, 32, 63, 0, 0, 0, 0, 0, 0,
]);
const RIGHT_TEE_GLYPH = new Uint8Array([
1, 1, 1, 1, 1, 1, 63, 1, 1, 1, 1, 1, 1,
]);
const DOWN_TEE_GLYPH = new Uint8Array([0, 0, 0, 0, 0, 63, 8, 8, 8, 8, 8, 8, 8]);
const ELLIPSIS_GLYPH = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42, 0, 0]);
const LIGHT_SHADE_GLYPH = new Uint8Array([
33, 0, 0, 33, 0, 0, 33, 0, 0, 33, 0, 0, 33,
]);
const MEDIUM_SHADE_GLYPH = new Uint8Array([
36, 9, 36, 9, 36, 9, 36, 9, 36, 9, 36, 9, 36,
]);
const DARK_SHADE_GLYPH = new Uint8Array([
45, 54, 45, 54, 45, 54, 45, 54, 45, 54, 45, 54, 45,
]);
const LOWER_ONE_EIGHTH_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 63,
]);
const LOWER_TWO_EIGHTHS_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 63, 63,
]);
const LOWER_THREE_EIGHTHS_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 0, 0, 0, 63, 63, 63, 63, 63,
]);
const LOWER_FIVE_EIGHTHS_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 0, 0, 63, 63, 63, 63, 63, 63, 63, 63,
]);
const LOWER_SIX_EIGHTHS_BLOCK_GLYPH = new Uint8Array([
0, 0, 0, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63,
]);
const LOWER_SEVEN_EIGHTHS_BLOCK_GLYPH = new Uint8Array([
0, 0, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63,
]);

/** Raster glyphs needed by the dashboard's box, sparkline, and text output. */
const DASHBOARD_GLYPHS = new Map<number, Uint8Array>([
[UPPER_HALF_BLOCK_CODE_POINT, UPPER_HALF_BLOCK_GLYPH],
[LOWER_HALF_BLOCK_CODE_POINT, LOWER_HALF_BLOCK_GLYPH],
[HORIZONTAL_RULE_CODE_POINT, HORIZONTAL_RULE_GLYPH],
[VERTICAL_RULE_CODE_POINT, VERTICAL_RULE_GLYPH],
[LOWER_LEFT_CORNER_CODE_POINT, LOWER_LEFT_CORNER_GLYPH],
[RIGHT_TEE_CODE_POINT, RIGHT_TEE_GLYPH],
[DOWN_TEE_CODE_POINT, DOWN_TEE_GLYPH],
[ELLIPSIS_CODE_POINT, ELLIPSIS_GLYPH],
[LIGHT_SHADE_CODE_POINT, LIGHT_SHADE_GLYPH],
[MEDIUM_SHADE_CODE_POINT, MEDIUM_SHADE_GLYPH],
[DARK_SHADE_CODE_POINT, DARK_SHADE_GLYPH],
[LOWER_ONE_EIGHTH_BLOCK_CODE_POINT, LOWER_ONE_EIGHTH_BLOCK_GLYPH],
[LOWER_TWO_EIGHTHS_BLOCK_CODE_POINT, LOWER_TWO_EIGHTHS_BLOCK_GLYPH],
[LOWER_THREE_EIGHTHS_BLOCK_CODE_POINT, LOWER_THREE_EIGHTHS_BLOCK_GLYPH],
[LOWER_FIVE_EIGHTHS_BLOCK_CODE_POINT, LOWER_FIVE_EIGHTHS_BLOCK_GLYPH],
[LOWER_SIX_EIGHTHS_BLOCK_CODE_POINT, LOWER_SIX_EIGHTHS_BLOCK_GLYPH],
[LOWER_SEVEN_EIGHTHS_BLOCK_CODE_POINT, LOWER_SEVEN_EIGHTHS_BLOCK_GLYPH],
]);

/**
Expand All @@ -58,14 +141,30 @@ const GLYPH_ROWS = Uint8Array.from(
/** Look up a Cozette glyph by character without parsing or rasterizing a font. */
export function getCozetteGlyph(character: string): Uint8Array {
const codePoint = character.codePointAt(0);
const index = getGlyphIndex(codePoint);
if (index === undefined) {
return UNKNOWN_GLYPH;
const dashboardGlyph = getDashboardGlyph(codePoint);
if (dashboardGlyph) {
return dashboardGlyph;
}
const start = index * COZETTE_CELL_HEIGHT;
const index = getGlyphIndex(codePoint);
const start = (index ?? QUESTION_MARK_INDEX) * COZETTE_CELL_HEIGHT;
return GLYPH_ROWS.subarray(start, start + COZETTE_CELL_HEIGHT);
}

/** Whether the embedded font can represent a character without substitution. */
export function hasCozetteGlyph(character: string): boolean {
const codePoint = character.codePointAt(0);
return (
getDashboardGlyph(codePoint) !== undefined ||
getGlyphIndex(codePoint) !== undefined
);
}

function getDashboardGlyph(
codePoint: number | undefined
): Uint8Array | undefined {
return codePoint === undefined ? undefined : DASHBOARD_GLYPHS.get(codePoint);
}

function getGlyphIndex(codePoint: number | undefined): number | undefined {
if (
codePoint !== undefined &&
Expand Down
119 changes: 105 additions & 14 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import type {
WidgetDataResult,
} from "../../types/dashboard.js";
import { encodeImageToKitty } from "../kitty-image.js";
import { logger } from "../logger.js";
import {
detectSixelCaps,
type GraphicsFormat,
type GraphicsRendererPreference,
graphicsCellSize,
selectGraphicsFormat,
terminalPixelWidth,
Expand All @@ -46,6 +50,8 @@ export type DashboardViewData = {
url: string;
dateCreated?: string;
environment?: string[];
/** Renderer selected by --renderer; omitted for API/JSON output. */
rendererPreference?: GraphicsRendererPreference;
widgets: DashboardViewWidget[];
};

Expand Down Expand Up @@ -1992,9 +1998,10 @@ export function formatDashboardWithData(data: DashboardViewData): string {
const lines: string[] = [];
lines.push(...renderHeader(data, termWidth));

const sixel = renderCompleteDashboardAsSixel(data, getSixelTermWidth());
if (sixel) {
lines.push(sixel);
const graphics = renderCompleteDashboardAsGraphics(data, getSixelTermWidth());
logDashboardGraphicsRenderer(graphics);
if (graphics.output) {
lines.push(graphics.output);
} else {
lines.push(...renderGrid(data.widgets, termWidth));
}
Expand All @@ -2010,20 +2017,79 @@ export function formatDashboardWithData(data: DashboardViewData): string {
* for kitty terminals, which frequently answer the graphics query but never
* send `CSI 16 t` — default cell dimensions are used so the dashboard still
* renders as graphics rather than silently dropping to ASCII. Never partially
* replaces the framebuffer: when no graphics format is available it returns
* `undefined` so the caller falls back to the complete character rendering.
* replaces the framebuffer: when no graphics format is available it records
* the fallback reason so the caller can render the complete character view and
* emit a useful debug log.
*/
function renderCompleteDashboardAsSixel(
type DashboardGraphicsRender = {
renderer: GraphicsFormat | "ascii";
rendererPreference: GraphicsRendererPreference;
output?: string;
reason?: string;
termWidth?: number;
cell?: { cellWidth: number; cellHeight: number };
pixelWidth?: number;
};

/** Log the terminal graphics decision without including dashboard data. */
function logDashboardGraphicsRenderer(render: DashboardGraphicsRender): void {
const caps = detectSixelCaps();
const details = [
`requested=${render.rendererPreference}`,
`capabilities: kitty=${caps.kitty === true ? "yes" : "no"}, sixel=${caps.supported ? "yes" : "no"}`,
`terminal: stdout TTY=${process.stdout.isTTY ? "yes" : "no"}, stdin TTY=${process.stdin.isTTY ? "yes" : "no"}`,
`plain output=${isPlainOutput() ? "yes" : "no"}`,
`columns=${render.termWidth ?? "unavailable"}`,
];
if (render.cell) {
details.push(`cell=${render.cell.cellWidth}x${render.cell.cellHeight}`);
}
if (render.pixelWidth) {
details.push(`pixel width=${render.pixelWidth}`);
}
const reason = render.reason ? ` (reason: ${render.reason})` : "";
logger.debug(
`Dashboard graphics renderer: ${render.renderer}${reason}; ${details.join("; ")}`
);
}

function renderCompleteDashboardAsGraphics(
data: DashboardViewData,
termWidth: number | undefined
): string | undefined {
const format = selectGraphicsFormat();
if (!termWidth || isPlainOutput() || !format) {
return;
): DashboardGraphicsRender {
const rendererPreference = data.rendererPreference ?? "auto";
const format = selectGraphicsFormat(rendererPreference);
if (!termWidth) {
return {
renderer: "ascii",
rendererPreference,
reason: "terminal width unavailable",
};
}
if (isPlainOutput()) {
return {
renderer: "ascii",
rendererPreference,
reason: "plain output requested",
termWidth,
};
}
const cell = graphicsCellSize();
if (!format) {
return {
renderer: "ascii",
rendererPreference,
reason: "no compatible graphics protocol detected",
termWidth,
};
}
const cell = graphicsCellSize(rendererPreference);
if (!cell) {
return;
return {
renderer: "ascii",
rendererPreference,
reason: "graphics cell size unavailable",
termWidth,
};
}
// Preserve the terminal's reported pixel width when known; otherwise derive it
// from the (possibly defaulted) cell width so the canvas still matches the
Expand All @@ -2032,9 +2098,16 @@ function renderCompleteDashboardAsSixel(
terminalPixelWidth(termWidth) ?? termWidth * cell.cellWidth;
const cellWidth = Math.floor(pixelWidth / termWidth);
if (cellWidth < 1) {
return;
return {
renderer: "ascii",
rendererPreference,
reason: "calculated graphics cell width is invalid",
termWidth,
cell,
pixelWidth,
};
}
return renderDashboardAsSixel(data, {
const graphics = renderDashboardAsSixel(data, {
pixelWidth,
cellWidth,
cellHeight: cell.cellHeight,
Expand All @@ -2050,6 +2123,24 @@ function renderCompleteDashboardAsSixel(
? (image) => encodeImageToKitty(image, image.width, true)
: undefined,
});
if (!("output" in graphics)) {
return {
renderer: "ascii",
rendererPreference,
reason: graphics.reason,
termWidth,
cell,
pixelWidth,
};
}
return {
renderer: format,
rendererPreference,
output: graphics.output,
termWidth,
cell,
pixelWidth,
};
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading