diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 8c318169b..305f9c494 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -43,6 +43,7 @@ View a dashboard - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` - `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"` +- `--renderer - Graphics renderer (defaults to auto; falls back to auto when unavailable) - (default: "auto")` **Examples:** diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index f8d1bfeff..8213034da 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -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, @@ -52,6 +53,7 @@ type ViewFlags = { readonly fresh: boolean; readonly refresh?: number; readonly period?: TimeRange; + readonly renderer: GraphicsRendererPreference; readonly json: boolean; readonly fields?: string[]; }; @@ -107,7 +109,11 @@ function buildViewData( }, widgetResults: Map, widgets: DashboardWidget[], - opts: { period: string; url: string } + opts: { + period: string; + rendererPreference: GraphicsRendererPreference; + url: string; + } ): DashboardViewData { return { id: dashboard.id, @@ -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, @@ -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: { @@ -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, @@ -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, }); @@ -316,6 +333,7 @@ export const viewCommand = buildCommand({ yield new CommandOutput( buildViewData(dashboard, widgetData, widgets, { period: formatTimeRangeFlag(timeRange), + rendererPreference: flags.renderer, url, }) ); diff --git a/packages/cli/src/lib/formatters/cozette-font.ts b/packages/cli/src/lib/formatters/cozette-font.ts index e0b6781be..947d058e9 100644 --- a/packages/cli/src/lib/formatters/cozette-font.ts +++ b/packages/cli/src/lib/formatters/cozette-font.ts @@ -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([ + [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], ]); /** @@ -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 && diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 0bf701db8..d3f56826e 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -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, @@ -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[]; }; @@ -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)); } @@ -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 @@ -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, @@ -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, + }; } // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/formatters/pixel-canvas.ts b/packages/cli/src/lib/formatters/pixel-canvas.ts index 6dc081ba5..2f1244033 100644 --- a/packages/cli/src/lib/formatters/pixel-canvas.ts +++ b/packages/cli/src/lib/formatters/pixel-canvas.ts @@ -3,8 +3,8 @@ * * Charts and dashboards use this module to draw into the RGBA buffers consumed * by the sixel encoder. The text renderer is deliberately limited to a compact - * terminal-sized bitmap font: unsupported glyphs remain visible as a fallback - * box instead of silently disappearing from a dashboard image. + * terminal-sized bitmap font. Common punctuation and diacritics are normalized + * before drawing so they remain legible even outside the embedded font subset. */ import type { DecodedImage } from "../sixel-image.js"; @@ -12,8 +12,27 @@ import { COZETTE_CELL_HEIGHT, COZETTE_CELL_WIDTH, getCozetteGlyph, + hasCozetteGlyph, } from "./cozette-font.js"; +/** Unicode punctuation that has a clear one- or two-cell ASCII equivalent. */ +const BITMAP_TEXT_FALLBACKS = new Map([ + ["–", "-"], + ["—", "-"], + ["−", "-"], + ["“", '"'], + ["”", '"'], + ["‘", "'"], + ["’", "'"], + ["•", "*"], + ["→", "->"], + ["←", "<-"], + ["↔", "<->"], + [" ", " "], +]); + +const COMBINING_MARK_RE = /\p{Mark}/gu; + /** An RGB color tuple with one 0-255 value per channel. */ export type Rgb = [number, number, number]; @@ -162,10 +181,13 @@ export function drawPixelText( const cellHeight = Math.max(1, Math.floor(options.cellHeight)); let column = 0; - for (const rawChar of text) { - if (column >= options.maxColumns) { + const characters = normalizeBitmapText(text)[Symbol.iterator](); + while (column < options.maxColumns) { + const character = characters.next(); + if (character.done) { break; } + const rawChar = character.value; drawCozetteGlyph(image, getCozetteGlyph(rawChar), { x: options.x + column * cellWidth, y: options.y, @@ -177,6 +199,32 @@ export function drawPixelText( } } +/** Convert common Unicode text into glyphs that the embedded font can draw. */ +function* normalizeBitmapText(text: string): Iterable { + for (const character of text) { + if (hasCozetteGlyph(character)) { + yield character; + continue; + } + const fallback = BITMAP_TEXT_FALLBACKS.get(character); + if (fallback) { + yield* fallback; + continue; + } + const decomposed = character + .normalize("NFKD") + .replace(COMBINING_MARK_RE, ""); + if ( + decomposed.length > 0 && + [...decomposed].every((candidate) => hasCozetteGlyph(candidate)) + ) { + yield* decomposed; + continue; + } + yield "?"; + } +} + type CozetteGlyphOptions = { x: number; y: number; diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index dbac48c9b..b1e47be84 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -48,6 +48,9 @@ const TERMINAL_ESCAPE_RE = new RegExp( "g" ); +/** Aggregate function and its simple arguments in a raw query expression. */ +const AGGREGATE_LABEL_RE = /^([a-z][a-z0-9_]*)\(([^()]*)\)/i; + /** A widget layout used by the dashboard grid. */ export type SixelWidgetLayout = { /** Left grid column. */ @@ -105,14 +108,19 @@ type PositionedWidget = { layout: SixelWidgetLayout; }; +/** Result of composing the dashboard image, including a safe fallback reason. */ +export type RenderSixelDashboardResult = + | { output: string } + | { reason: string }; + /** Render every dashboard widget into one terminal-positioned sixel image. */ export function renderDashboardAsSixel( data: SixelDashboardData, options: RenderSixelDashboardOptions -): string | undefined { +): RenderSixelDashboardResult { const widgets = positionWidgets(data.widgets); if (widgets.length === 0) { - return; + return { reason: "no dashboard widgets" }; } const pixelWidth = Math.max(1, Math.floor(options.pixelWidth)); @@ -120,8 +128,14 @@ export function renderDashboardAsSixel( ...widgets.map((item) => item.layout.y + item.layout.h) ); const pixelHeight = gridHeight * LINES_PER_GRID_UNIT * options.cellHeight; - if (pixelWidth * pixelHeight > MAX_CANVAS_PIXELS) { - return; + const canvasPixels = pixelWidth * pixelHeight; + if (canvasPixels > MAX_CANVAS_PIXELS) { + return { + reason: + `canvas ${pixelWidth}x${pixelHeight} ` + + `(${(canvasPixels / 1_000_000).toFixed(2)}M pixels) exceeds the ` + + `${MAX_CANVAS_PIXELS / 1_000_000}M pixel limit`, + }; } const image = createPixelCanvas({ width: pixelWidth, height: pixelHeight }); @@ -132,7 +146,10 @@ export function renderDashboardAsSixel( // terminal width exactly to preserve the dashboard grid. const encode = options.encodeImage ?? ((img) => encodeImageToSixel(img, img.width, true)); - return encode(image); + const output = encode(image); + return output + ? { output } + : { reason: "graphics encoder produced no output" }; } /** Place layout-less widgets beneath the explicit dashboard grid. */ @@ -182,6 +199,7 @@ function drawWidget( drawChartContent(image, { data: widget.data, categorical: widget.displayType === "categorical_bar", + heatmap: widget.displayType === "heatmap", x: contentX, y: contentY, width: contentWidth, @@ -252,17 +270,30 @@ function tryRenderSpecialChart( options: Parameters[1] ): boolean { if (options.data.series.length === 0) { - drawPixelText(image, "(NO DATA)", { - x: options.x, - y: options.y, + const label = "NO DATA"; + const maxColumns = Math.max( + 1, + Math.floor(options.width / options.cellWidth) + ); + const labelColumns = Math.min(label.length, maxColumns); + drawPixelText(image, label, { + x: + options.x + + Math.max( + 0, + Math.floor((options.width - labelColumns * options.cellWidth) / 2) + ), + y: + options.y + + Math.max(0, Math.floor((options.height - options.cellHeight) / 2)), cellWidth: options.cellWidth, cellHeight: options.cellHeight, - maxColumns: Math.max(1, Math.floor(options.width / options.cellWidth)), + maxColumns, color: FRAME_COLOR, }); return true; } - if (!options.categorical) { + if (options.heatmap) { const hm = buildHeatmapModel(options.data); if (hm) { const chart = rasterizeHeatmap(hm, { @@ -285,6 +316,7 @@ function drawChartContent( options: { data: Extract; categorical: boolean; + heatmap: boolean; x: number; y: number; width: number; @@ -461,7 +493,11 @@ function drawLegend( if (!series || column >= maxColumns) { break; } - const label = series.label.slice(0, 12); + const availableColumns = maxColumns - column - 2; + const label = truncateLegendLabel( + formatLegendLabel(series.label), + Math.min(18, availableColumns) + ); const requiredColumns = Math.min(maxColumns, label.length + 2); if (column + requiredColumns > maxColumns) { break; @@ -485,6 +521,33 @@ function drawLegend( } } +/** Turn raw aggregate expressions into concise, readable legend labels. */ +export function formatLegendLabel(label: string): string { + const aggregate = AGGREGATE_LABEL_RE.exec(label); + if (aggregate) { + const operation = aggregate[1] ?? label; + const arguments_ = + aggregate[2]?.split(",").map((argument) => argument.trim()) ?? []; + const metric = arguments_[0] === "value" ? arguments_[1] : arguments_[0]; + return metric ? `${operation} ${metric}` : operation; + } + if (label.startsWith("equation|")) { + return "equation"; + } + return label; +} + +/** Clip a legend label without leaving a partial aggregate expression. */ +function truncateLegendLabel(label: string, maxColumns: number): string { + if (label.length <= maxColumns) { + return label; + } + if (maxColumns <= 3) { + return label.slice(0, Math.max(0, maxColumns)); + } + return `${label.slice(0, maxColumns - 3)}...`; +} + /** Format a number compactly enough for the chart-axis gutter. */ function formatChartValue( value: number, diff --git a/packages/cli/src/lib/sixel.ts b/packages/cli/src/lib/sixel.ts index a1f2cfe9d..135806860 100644 --- a/packages/cli/src/lib/sixel.ts +++ b/packages/cli/src/lib/sixel.ts @@ -61,21 +61,47 @@ export const DEFAULT_CELL_HEIGHT = 20; export type GraphicsFormat = "kitty" | "sixel"; /** - * Pick the best available terminal graphics format, or `undefined` when the - * terminal can only render ASCII. Kitty and sixel are usually mutually - * exclusive, but when a terminal advertises both, kitty wins because it - * transmits full RGBA without palette quantization. + * The graphics renderer requested by a dashboard invocation. A requested + * protocol is preferred when available; otherwise selection falls back to the + * normal automatic order (kitty, then sixel, then ASCII). */ -export function selectGraphicsFormat(): GraphicsFormat | undefined { - if (canRenderKitty()) { +export type GraphicsRendererPreference = "auto" | GraphicsFormat; + +/** Select a graphics renderer from the protocols currently available. */ +export function selectGraphicsFormatFromAvailability( + preference: GraphicsRendererPreference, + available: { kitty: boolean; sixel: boolean } +): GraphicsFormat | undefined { + if (preference === "kitty" && available.kitty) { + return "kitty"; + } + if (preference === "sixel" && available.sixel) { + return "sixel"; + } + if (available.kitty) { return "kitty"; } - if (canRenderSixel()) { + if (available.sixel) { return "sixel"; } return; } +/** + * Pick the best available terminal graphics format, or `undefined` when the + * terminal can only render ASCII. Kitty and sixel are usually mutually + * exclusive, but when a terminal advertises both, kitty wins because it + * transmits full RGBA without palette quantization. + */ +export function selectGraphicsFormat( + preference: GraphicsRendererPreference = "auto" +): GraphicsFormat | undefined { + return selectGraphicsFormatFromAvailability(preference, { + kitty: canRenderKitty(), + sixel: canRenderSixel(), + }); +} + /** * The character-cell dimensions in device pixels for the current terminal, or * `undefined` when no graphics format is available. Uses the terminal's @@ -83,10 +109,10 @@ export function selectGraphicsFormat(): GraphicsFormat | undefined { * {@link DEFAULT_CELL_HEIGHT} otherwise, so a graphics-capable terminal that * never reports its geometry still renders graphics instead of ASCII. */ -export function graphicsCellSize(): - | { cellWidth: number; cellHeight: number } - | undefined { - if (!selectGraphicsFormat()) { +export function graphicsCellSize( + preference: GraphicsRendererPreference = "auto" +): { cellWidth: number; cellHeight: number } | undefined { + if (!selectGraphicsFormat(preference)) { return; } const caps = detectSixelCaps(); diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts index 402863f81..f64bfabfb 100644 --- a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -13,6 +13,7 @@ import { type DashboardViewWidget, formatDashboardWithData, } from "../../../src/lib/formatters/dashboard.js"; +import { logger } from "../../../src/lib/logger.js"; // biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking import * as sixelModule from "../../../src/lib/sixel.js"; import type { TimeseriesResult } from "../../../src/types/dashboard.js"; @@ -75,6 +76,9 @@ describe("dashboard sixel integration", () => { process.env.SENTRY_PLAIN_OUTPUT = "0"; process.stdout.columns = 40; vi.spyOn(sixelModule, "selectGraphicsFormat").mockReturnValue("sixel"); + vi.spyOn(sixelModule, "detectSixelCaps").mockReturnValue({ + supported: true, + }); vi.spyOn(sixelModule, "graphicsCellSize").mockReturnValue({ cellWidth: 8, cellHeight: 12, @@ -110,8 +114,25 @@ describe("dashboard sixel integration", () => { expect(output).toContain('"1;1;320;144'); }); + test("logs the selected sixel graphics renderer and its detected capabilities", () => { + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: sixel") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("capabilities: kitty=no, sixel=yes") + ); + }); + test("prefers kitty encoding when the terminal supports it", () => { vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); const data = makeDashboardData({ widgets: [ makeWidget({ @@ -128,6 +149,55 @@ describe("dashboard sixel integration", () => { expect(output).not.toContain(`${ESC}P`); }); + test("uses a requested sixel renderer instead of an available kitty renderer", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockImplementation( + (renderer) => (renderer === "sixel" ? "sixel" : "kitty") + ); + + const output = formatDashboardWithData( + makeDashboardData({ rendererPreference: "sixel" }) + ); + + expect(output).toContain(`${ESC}P`); + expect(output).not.toContain(`${ESC}_G`); + }); + + test("logs the requested renderer when auto fallback selects kitty", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData( + makeDashboardData({ rendererPreference: "sixel" }) + ); + + expect(output).toContain(`${ESC}_G`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("requested=sixel") + ); + }); + + test("logs kitty when it is the selected graphics renderer", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue("kitty"); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + kitty: true, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: kitty") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("capabilities: kitty=yes, sixel=no") + ); + }); + test("renders scalar and timeseries widgets in the same sixel canvas", () => { const data = makeDashboardData({ widgets: [ @@ -278,6 +348,9 @@ describe("dashboard sixel integration", () => { test("falls back to the complete character dashboard without a graphics format", () => { vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue(undefined); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + }); vi.mocked(sixelModule.graphicsCellSize).mockReturnValue(undefined); const output = formatDashboardWithData( makeDashboardData({ @@ -295,6 +368,64 @@ describe("dashboard sixel integration", () => { expect(output).toContain("Fallback Widget"); }); + test("logs ASCII and the fallback reason when no graphics renderer is available", () => { + vi.mocked(sixelModule.selectGraphicsFormat).mockReturnValue(undefined); + vi.mocked(sixelModule.detectSixelCaps).mockReturnValue({ + supported: false, + }); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue(undefined); + const debugSpy = vi.spyOn(logger, "debug"); + + formatDashboardWithData(makeDashboardData()); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: ascii") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "reason: no compatible graphics protocol detected" + ) + ); + }); + + test("logs ASCII when the selected graphics renderer cannot create a canvas", () => { + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData(makeDashboardData({ widgets: [] })); + + expect(output).not.toContain(`${ESC}P`); + expect(output).not.toContain(`${ESC}_G`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("Dashboard graphics renderer: ascii") + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining("reason: no dashboard widgets") + ); + }); + + test("logs canvas dimensions when the dashboard exceeds the graphics safety cap", () => { + process.stdout.columns = 244; + vi.mocked(sixelModule.terminalPixelWidth).mockReturnValue(3416); + vi.mocked(sixelModule.graphicsCellSize).mockReturnValue({ + cellWidth: 14, + cellHeight: 32, + }); + const debugSpy = vi.spyOn(logger, "debug"); + + const output = formatDashboardWithData( + makeDashboardData({ + widgets: [makeWidget({ layout: { x: 0, y: 12, w: 6, h: 1 } })], + }) + ); + + expect(output).not.toContain(`${ESC}P`); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining( + "reason: canvas 3416x2496 (8.53M pixels) exceeds the 8M pixel limit" + ) + ); + }); + test("renders graphics on a kitty terminal that never reports cell geometry", () => { // Regression for #1506: kitty terminals frequently answer the graphics // query but never send `CSI 16 t`, so terminalPixelWidth is undefined. diff --git a/packages/cli/test/lib/formatters/pixel-canvas.test.ts b/packages/cli/test/lib/formatters/pixel-canvas.test.ts index ddca1d59b..ab1f090c2 100644 --- a/packages/cli/test/lib/formatters/pixel-canvas.test.ts +++ b/packages/cli/test/lib/formatters/pixel-canvas.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { getCozetteGlyph } from "../../../src/lib/formatters/cozette-font.js"; import { createPixelCanvas, @@ -37,6 +37,71 @@ describe("drawPixelText", () => { expect(getCozetteGlyph("A")[2]).toBe(0b00_1110); }); + test("includes the half-block and rule glyphs used by dashboard content", () => { + expect([...getCozetteGlyph("▀")]).toEqual([ + 63, 63, 63, 63, 63, 63, 0, 0, 0, 0, 0, 0, 0, + ]); + expect([...getCozetteGlyph("▄")]).toEqual([ + 0, 0, 0, 0, 0, 0, 63, 63, 63, 63, 63, 63, 63, + ]); + expect([...getCozetteGlyph("─")]).toEqual([ + 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 0, 0, 0, + ]); + }); + + test("uses a question mark for unsupported text glyphs", () => { + expect([...getCozetteGlyph("\u6f22")]).toEqual([...getCozetteGlyph("?")]); + }); + + test("includes the dashboard axis, sparkline, and ellipsis glyphs", () => { + const questionMark = [...getCozetteGlyph("?")]; + for (const character of [ + "│", + "└", + "┤", + "┬", + "…", + "▁", + "▂", + "▃", + "▄", + "▅", + "▆", + "▇", + "░", + "▓", + ]) { + expect([...getCozetteGlyph(character)]).not.toEqual(questionMark); + } + }); + + test("renders accented labels and typographic punctuation legibly", () => { + const rendered = renderText("Café — 50…"); + const expected = renderText("Cafe - 50…"); + + expect(rendered.data).toEqual(expected.data); + }); + + test("does not normalize text outside its visible width", () => { + const normalize = vi.spyOn(String.prototype, "normalize"); + const image = createPixelCanvas({ width: 6, height: 13 }); + + try { + drawPixelText(image, `A${"é".repeat(100)}`, { + x: 0, + y: 0, + cellWidth: 6, + cellHeight: 13, + maxColumns: 1, + color: [255, 255, 255], + }); + + expect(normalize).not.toHaveBeenCalled(); + } finally { + normalize.mockRestore(); + } + }); + test("fills non-integer terminal cells with proportional bitmap scaling", () => { const image = createPixelCanvas({ width: 9, height: 18 }); @@ -93,3 +158,16 @@ describe("drawPixelText", () => { } }); }); + +function renderText(text: string) { + const image = createPixelCanvas({ width: 120, height: 13 }); + drawPixelText(image, text, { + x: 0, + y: 0, + cellWidth: 6, + cellHeight: 13, + maxColumns: 20, + color: [255, 255, 255], + }); + return image; +} diff --git a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts index 6abeddf60..99e23a63c 100644 --- a/packages/cli/test/lib/formatters/sixel-dashboard.test.ts +++ b/packages/cli/test/lib/formatters/sixel-dashboard.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "vitest"; -import { formatTimestamp } from "../../../src/lib/formatters/sixel-dashboard.js"; +import { + formatLegendLabel, + formatTimestamp, + renderDashboardAsSixel, +} from "../../../src/lib/formatters/sixel-dashboard.js"; +import type { DecodedImage } from "../../../src/lib/sixel-image.js"; describe("formatTimestamp", () => { const timestamp = Date.UTC(2024, 0, 15, 10, 30) / 1000; @@ -28,3 +33,160 @@ describe("formatTimestamp", () => { expect(formatTimestamp(timestamp, 31)).toBe(`Jan ${date.getDate()}`); }); }); + +function renderWidget(displayType: string): { + image: DecodedImage; + result: ReturnType; +} { + let captured: DecodedImage | undefined; + const result = renderDashboardAsSixel( + { + widgets: [ + { + title: "Requests", + displayType, + layout: { x: 0, y: 0, w: 6, h: 2 }, + data: { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 1 }, + { timestamp: 1_700_000_060, value: 4 }, + ], + }, + ], + }, + }, + ], + }, + { + pixelWidth: 120, + cellWidth: 10, + cellHeight: 20, + renderTextContent: () => [], + encodeImage(image) { + captured = image; + return "rendered"; + }, + } + ); + + if (!captured) { + throw new Error("Test encoder did not receive a dashboard image"); + } + return { image: captured, result }; +} + +function countPixels( + image: DecodedImage, + color: [number, number, number] +): number { + let count = 0; + for (let offset = 0; offset < image.data.length; offset += 4) { + if ( + image.data[offset] === color[0] && + image.data[offset + 1] === color[1] && + image.data[offset + 2] === color[2] + ) { + count += 1; + } + } + return count; +} + +describe("dashboard chart rendering", () => { + test("uses heatmap cells only for heatmap widgets", () => { + const heatmapCell: [number, number, number] = [80, 120, 200]; + const line = renderWidget("line"); + const heatmap = renderWidget("heatmap"); + + expect(line.result).toEqual({ output: "rendered" }); + expect(heatmap.result).toEqual({ output: "rendered" }); + expect(countPixels(line.image, heatmapCell)).toBe(0); + expect(countPixels(heatmap.image, heatmapCell)).toBeGreaterThan(0); + }); + + test("uses compact aggregate names in the graphics legend", () => { + expect(formatLegendLabel("p50(span.duration)")).toBe("p50 span.duration"); + expect(formatLegendLabel("p95(value,web.vital,distribution,none)")).toBe( + "p95 web.vital" + ); + expect( + formatLegendLabel("p95(value,backend.duration,distribution,none)") + ).toBe("p95 backend.duration"); + expect(formatLegendLabel("GET /api/projects")).toBe("GET /api/projects"); + }); + + test("centers the no-data state inside a chart widget", () => { + let captured: DecodedImage | undefined; + const result = renderDashboardAsSixel( + { + widgets: [ + { + title: "Cost by Agent", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 3 }, + data: { type: "timeseries", series: [] }, + }, + ], + }, + { + pixelWidth: 180, + cellWidth: 10, + cellHeight: 20, + renderTextContent: () => [], + encodeImage(image) { + captured = image; + return "rendered"; + }, + } + ); + + if (!captured) { + throw new Error("Test encoder did not receive a dashboard image"); + } + + expect(result).toEqual({ output: "rendered" }); + expect( + countPixelsInRegion(captured, [128, 128, 128], { + x: 10, + y: 20, + width: 70, + height: 20, + }) + ).toBe(0); + expect( + countPixelsInRegion(captured, [128, 128, 128], { + x: 50, + y: 160, + width: 80, + height: 40, + }) + ).toBeGreaterThan(0); + }); +}); + +function countPixelsInRegion( + image: DecodedImage, + color: [number, number, number], + region: { x: number; y: number; width: number; height: number } +): number { + let count = 0; + const endX = region.x + region.width; + const endY = region.y + region.height; + for (let y = region.y; y < endY; y += 1) { + for (let x = region.x; x < endX; x += 1) { + const offset = (y * image.width + x) * 4; + if ( + image.data[offset] === color[0] && + image.data[offset + 1] === color[1] && + image.data[offset + 2] === color[2] + ) { + count += 1; + } + } + } + return count; +} diff --git a/packages/cli/test/lib/sixel.test.ts b/packages/cli/test/lib/sixel.test.ts index 2e09cbc06..c9686f5f2 100644 --- a/packages/cli/test/lib/sixel.test.ts +++ b/packages/cli/test/lib/sixel.test.ts @@ -27,6 +27,7 @@ import { parseSixelCaps, readReply, selectGraphicsFormat, + selectGraphicsFormatFromAvailability, sixelBanner, sixelFits, terminalPixelWidth, @@ -152,6 +153,15 @@ describe("selectGraphicsFormat", () => { __resetSixelCache(); expect(selectGraphicsFormat()).toBeUndefined(); }); + + test("falls back to the automatic renderer when the requested one is unavailable", () => { + expect( + selectGraphicsFormatFromAvailability("sixel", { + kitty: true, + sixel: false, + }) + ).toBe("kitty"); + }); }); describe("graphicsCellSize", () => {