From 39c0afbcae0a7fbd3331b6c41a89e281fb46d322 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 11 Aug 2026 10:44:32 +0000 Subject: [PATCH 1/5] feat(dashboard): sixel timeseries via shared pixel-core renderer Rework the opt-in sixel timeseries rendering (#1231) around a single resolution-independent chart core instead of a standalone sixel-only renderer. chart-core.ts builds a ChartModel from a TimeseriesResult and rasterizes it into an RGBA canvas at a caller-chosen resolution. The sixel path (pixel resolution) is now a thin wrapper over the core, and the ASCII renderer shares the same categorical palette, so both outputs agree on hue and stacking. Output target picks the resolution upfront. Opt-in via --sixel / SENTRY_DASHBOARD_SIXEL=1 / displayType timeseries_sixel; falls back to text when the terminal lacks support. Closes #1231 --- .../skills/sentry-cli/references/dashboard.md | 1 + packages/cli/src/commands/dashboard/view.ts | 17 +- packages/cli/src/lib/formatters/chart-core.ts | 291 ++++++++++++++++++ packages/cli/src/lib/formatters/dashboard.ts | 78 +++-- packages/cli/src/lib/formatters/index.ts | 1 + .../src/lib/formatters/sixel-timeseries.ts | 58 ++++ .../test/lib/formatters/chart-core.test.ts | 149 +++++++++ .../dashboard-sixel-integration.test.ts | 146 +++++++++ .../lib/formatters/sixel-timeseries.test.ts | 109 +++++++ 9 files changed, 821 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/lib/formatters/chart-core.ts create mode 100644 packages/cli/src/lib/formatters/sixel-timeseries.ts create mode 100644 packages/cli/test/lib/formatters/chart-core.test.ts create mode 100644 packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts create mode 100644 packages/cli/test/lib/formatters/sixel-timeseries.test.ts 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 83c2c85ec1..8f57dcb176 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 @@ -40,6 +40,7 @@ View a dashboard **Flags:** - `-w, --web - Open in browser` +- `-s, --sixel - Render timeseries widgets as sixel images` - `-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"` diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 39390699c5..aa3d810aec 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -54,6 +54,7 @@ type ViewFlags = { readonly period?: TimeRange; readonly json: boolean; readonly fields?: string[]; + readonly sixel: boolean; }; /** @@ -188,6 +189,11 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + sixel: { + kind: "boolean", + brief: "Render timeseries widgets as sixel images", + default: false, + }, fresh: FRESH_FLAG, refresh: { kind: "parsed", @@ -203,10 +209,19 @@ export const viewCommand = buildCommand({ optional: true, }, }, - aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" }, + aliases: { + ...FRESH_ALIASES, + w: "web", + s: "sixel", + r: "refresh", + t: "period", + }, }, async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); + if (flags.sixel) { + process.env.SENTRY_DASHBOARD_SIXEL = "1"; + } const { cwd } = this; const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts new file mode 100644 index 0000000000..d7e18da429 --- /dev/null +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -0,0 +1,291 @@ +/** + * Shared timeseries chart core. + * + * Turns a {@link TimeseriesResult} into a resolution-independent + * {@link ChartModel}, then rasterizes that model into an RGBA pixel canvas. + * Both the sixel renderer (pixel resolution) and the ASCII renderer + * (character-cell resolution) consume this single core so the two paths + * agree on layout, palette, and stacking. The output resolution is chosen by + * the target and fed in upfront via {@link rasterizeChart}. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import type { DecodedImage } from "../sixel-image.js"; + +/** + * Chart color palette based on Sentry's categorical chart hues. + * + * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx + * (categorical.dark / categorical.light), adjusted to a mid-luminance range + * so every color achieves ≥3:1 contrast on both dark (#1e1e1e) and light + * (#f0f0f0) terminal backgrounds. "Other" always gets muted gray. + */ +export const SERIES_PALETTE = [ + "#7553FF", // blurple (Sentry primary) + "#F0369A", // pink + "#C06F20", // orange (darkened from #FF9838) + "#3D8F09", // green (darkened from #67C800) + "#8B6AC8", // purple (lightened from #5D3EB2) + "#E45560", // salmon (darkened from #FA6769) + "#B82D90", // magenta + "#9E8B18", // yellow (darkened from #FFD00E) + "#228A83", // teal (fills hue gap) + "#7B50D0", // indigo (lightened from #50219C) +] as const; + +/** Muted gray for the "Other" bucket. */ +export const OTHER_COLOR = "#888888"; + +/** Get the hex color for a series by index. "Other" gets muted gray. */ +export function seriesColor(label: string, index: number): string { + if (label === "Other") { + return OTHER_COLOR; + } + return SERIES_PALETTE[index % SERIES_PALETTE.length] ?? SERIES_PALETTE[0]; +} + +/** Parse an RGB hex color into a 3-tuple. */ +export function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.replace("#", ""); + if (normalized.length === 3) { + const r0 = normalized[0]; + const g0 = normalized[1]; + const b0 = normalized[2]; + if (r0 && g0 && b0) { + return [ + Number.parseInt(r0 + r0, 16), + Number.parseInt(g0 + g0, 16), + Number.parseInt(b0 + b0, 16), + ]; + } + } + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ]; +} + +/** One series in a chart model: a label plus its per-bucket values. */ +export type ChartSeries = { + label: string; + values: number[]; +}; + +/** + * Resolution-independent chart description. + * + * `buckets` is the number of time buckets (columns). `maxVal` is the + * axis maximum: the largest single value for a single series, or the largest + * per-bucket total for a stacked chart. `stacked` records whether the columns + * are drawn as stacked segments (multi-series) or as plain bars (single). + */ +export type ChartModel = { + series: ChartSeries[]; + buckets: number; + maxVal: number; + stacked: boolean; +}; + +/** Build a resolution-independent chart model from a timeseries result. */ +export function buildChartModel( + data: TimeseriesResult +): ChartModel | undefined { + if ( + data.series.length === 0 || + data.series.every((s) => s.values.length === 0) + ) { + return; + } + + const series: ChartSeries[] = data.series.map((s) => ({ + label: s.label, + values: s.values.map((v) => v.value), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + const stacked = series.length > 1; + + const maxVal = stacked + ? Math.max(...bucketTotals(series, buckets), 1) + : Math.max(...(series[0]?.values ?? []), 1); + + return { series, buckets, maxVal, stacked }; +} + +/** Sum each bucket across every series. */ +function bucketTotals(series: ChartSeries[], buckets: number): number[] { + const totals = new Array(buckets).fill(0); + for (const s of series) { + for (let i = 0; i < buckets; i++) { + const total = totals[i]; + const value = s.values[i]; + if (total !== undefined && value !== undefined) { + totals[i] = total + value; + } + } + } + return totals; +} + +/** RGBA for the default background when transparency is off. */ +const BACKGROUND_RGBA: [number, number, number, number] = [30, 30, 30, 255]; + +/** Options for {@link rasterizeChart}. */ +export type RasterizeOpts = { + /** Target canvas width in pixels. */ + width: number; + /** Target canvas height in pixels. */ + height: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** + * Rasterize a chart model into an RGBA pixel canvas at the given resolution. + * + * This is the pixel core: the resolution is chosen by the caller for its + * output target (sixel cell pixels, or an ASCII cell-grid multiple). Returns + * `undefined` when the model has no buckets to draw. + */ +export function rasterizeChart( + model: ChartModel, + opts: RasterizeOpts +): DecodedImage | undefined { + const width = Math.max(16, Math.floor(opts.width)); + const height = Math.max(8, Math.floor(opts.height)); + if (model.buckets === 0) { + return; + } + + const img = createCanvas(width, height, opts.backgroundTransparent ?? true); + const layout = computeBarLayout(width, model.buckets); + + if (model.stacked) { + drawStackedColumns(img, model, height, layout); + } else { + drawBars(img, model, height, layout); + } + + return img; +} + +/** Create an RGBA canvas, optionally transparent. */ +function createCanvas( + width: number, + height: number, + transparent: boolean +): DecodedImage { + const size = width * height * 4; + const data = new Uint8Array(size); + if (!transparent) { + const [r, g, b, a] = BACKGROUND_RGBA; + for (let i = 0; i < size; i += 4) { + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + } + return { width, height, data }; +} + +/** Gap and bar width for evenly distributed columns. */ +type BarLayout = { + gap: number; + barWidth: number; +}; + +/** Compute the gap and width for evenly distributed bars. */ +function computeBarLayout(width: number, count: number): BarLayout { + const gap = Math.max(1, Math.floor(width / count / 8)); + const barWidth = Math.max(1, Math.floor((width - (count - 1) * gap) / count)); + return { gap, barWidth }; +} + +/** Draw single-series bars into the canvas. */ +function drawBars( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + const series = model.series[0]; + if (!series) { + return; + } + const color = hexToRgb(seriesColor(series.label, 0)); + + for (let i = 0; i < series.values.length; i++) { + const value = series.values[i] ?? 0; + const h = Math.round((value / model.maxVal) * height); + const x0 = i * (layout.barWidth + layout.gap); + drawRect(img, { + x: x0, + y: height - h, + w: layout.barWidth, + h, + color, + }); + } +} + +/** Draw stacked multi-series columns into the canvas. */ +function drawStackedColumns( + img: DecodedImage, + model: ChartModel, + height: number, + layout: BarLayout +): void { + for (let b = 0; b < model.buckets; b++) { + const x0 = b * (layout.barWidth + layout.gap); + let yBottom = height; + + for (let s = 0; s < model.series.length; s++) { + const series = model.series[s]; + if (!series) { + continue; + } + const value = series.values[b] ?? 0; + if (value <= 0 || yBottom <= 0) { + continue; + } + + const segmentHeight = Math.min( + yBottom, + Math.max(1, Math.round((value / model.maxVal) * height)) + ); + const yTop = Math.max(0, yBottom - segmentHeight); + drawRect(img, { + x: x0, + y: yTop, + w: layout.barWidth, + h: yBottom - yTop, + color: hexToRgb(seriesColor(series.label, s)), + }); + yBottom = yTop; + } + } +} + +/** Parameters for {@link drawRect}. */ +type RectOpts = { + x: number; + y: number; + w: number; + h: number; + color: [number, number, number]; +}; + +/** Fill a solid rectangle in the canvas. */ +function drawRect(img: DecodedImage, opts: RectOpts): void { + const { x, y, w, h, color } = opts; + for (let py = Math.max(0, y); py < Math.min(img.height, y + h); py++) { + for (let px = Math.max(0, x); px < Math.min(img.width, x + w); px++) { + const i = (py * img.width + px) * 4; + img.data[i] = color[0]; + img.data[i + 1] = color[1]; + img.data[i + 2] = color[2]; + img.data[i + 3] = 255; + } + } +} diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 4889e647cc..7e4311ac66 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -19,11 +19,14 @@ import type { TimeseriesResult, WidgetDataResult, } from "../../types/dashboard.js"; +import { getEnv } from "../env.js"; +import { canRenderSixel, terminalPixelWidth } from "../sixel.js"; +import { SERIES_PALETTE } from "./chart-core.js"; import { COLORS, muted, terminalLink } from "./colors.js"; import { renderMarkdown } from "./markdown.js"; - import type { HumanRenderer } from "./output.js"; import { isPlainOutput } from "./plain-detect.js"; +import { renderTimeseriesAsSixel } from "./sixel-timeseries.js"; import { downsample, sparkline } from "./sparkline.js"; // --------------------------------------------------------------------------- @@ -1210,29 +1213,6 @@ function renderTimeBarRows( return rows; } -/** - * Chart color palette based on Sentry's categorical chart hues. - * - * Derived from sentry/static/app/utils/theme/scraps/tokens/color.tsx - * (categorical.dark / categorical.light), adjusted to a mid-luminance - * range so every color achieves ≥3:1 contrast on **both** dark (#1e1e1e) - * and light (#f0f0f0) terminal backgrounds. - * - * "Other" always gets muted gray (handled by seriesColor). - */ -const SERIES_PALETTE = [ - "#7553FF", // blurple (Sentry primary) - "#F0369A", // pink - "#C06F20", // orange (darkened from #FF9838) - "#3D8F09", // green (darkened from #67C800) - "#8B6AC8", // purple (lightened from #5D3EB2) - "#E45560", // salmon (darkened from #FA6769) - "#B82D90", // magenta - "#9E8B18", // yellow (darkened from #FFD00E) - "#228A83", // teal (fills hue gap) - "#7B50D0", // indigo (lightened from #50219C) -] as const; - /** * Fill characters for plain/no-color mode. * @@ -1241,7 +1221,13 @@ const SERIES_PALETTE = [ */ const PLAIN_FILLS = ["█", "▓", "▒", "#", "=", "*", "+", "~", ":", "."] as const; -/** Get the color for a series by index. "Other" gets muted gray. */ +/** + * Get the color for a series by index. "Other" gets muted gray. + * + * Shares {@link SERIES_PALETTE} with the pixel chart core so the ASCII and + * sixel renderers use identical hues; only the "Other" bucket differs (ANSI + * muted vs the core's hex gray). + */ function seriesColor(label: string, index: number): string { if (label === "Other") { return COLORS.muted; @@ -1546,7 +1532,25 @@ function renderContentLines(opts: { const { data } = widget; switch (data.type) { - case "timeseries": + case "timeseries": { + // Opt-in sixel image rendering for timeseries widgets. + const env = getEnv(); + if ( + !isPlainOutput() && + (env.SENTRY_DASHBOARD_SIXEL === "1" || + widget.displayType === "timeseries_sixel") && + canRenderSixel() + ) { + const pixelBudget = terminalPixelWidth(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: pixelBudget ?? innerWidth * 8, + maxPixelHeight: contentHeight * 12, + }); + if (sixel) { + return [sixel]; + } + } + if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } @@ -1555,6 +1559,7 @@ function renderContentLines(opts: { return renderTimeseriesBarsContent(data, { innerWidth, contentHeight }); } return renderTimeseriesContent(data, innerWidth); + } case "table": return renderTableContent(data, innerWidth); @@ -1616,7 +1621,7 @@ function renderWidgetLines( * If longer, it is truncated (ANSI-aware via character iteration). */ /** ANSI escape sequence type for the truncation state machine. */ -type EscapeType = "none" | "start" | "csi" | "osc"; +type EscapeType = "none" | "start" | "csi" | "osc" | "dcs"; /** Check if a character is an ASCII letter (CSI sequence terminator). */ function isAsciiLetter(ch: string): boolean { @@ -1635,6 +1640,15 @@ function advanceEscape( ch: string, buffer: string ): boolean { + return advanceEscapeInner(state, ch, buffer.at(-1)); +} + +function advanceEscapeInner( + state: { type: EscapeType }, + ch: string, + prev: string | undefined +): boolean { + const stTerminator = ch === "\\" && prev === "\x1b"; switch (state.type) { case "none": if (ch === "\x1b") { @@ -1647,6 +1661,8 @@ function advanceEscape( state.type = "csi"; } else if (ch === "]") { state.type = "osc"; + } else if (ch === "P") { + state.type = "dcs"; } else { state.type = "none"; } @@ -1658,7 +1674,13 @@ function advanceEscape( return true; case "osc": // OSC ends at BEL (\x07) or ST (\x1b\\) - if (ch === "\x07" || (ch === "\\" && buffer.at(-1) === "\x1b")) { + if (ch === "\x07" || stTerminator) { + state.type = "none"; + } + return true; + case "dcs": + // DCS ends at ST (\x1b\\) + if (stTerminator) { state.type = "none"; } return true; diff --git a/packages/cli/src/lib/formatters/index.ts b/packages/cli/src/lib/formatters/index.ts index 51c2381bcf..adca23422e 100644 --- a/packages/cli/src/lib/formatters/index.ts +++ b/packages/cli/src/lib/formatters/index.ts @@ -14,6 +14,7 @@ export * from "./markdown.js"; export * from "./numbers.js"; export * from "./output.js"; export * from "./seer.js"; +export * from "./sixel-timeseries.js"; export * from "./sparkline.js"; export * from "./table.js"; export * from "./time-utils.js"; diff --git a/packages/cli/src/lib/formatters/sixel-timeseries.ts b/packages/cli/src/lib/formatters/sixel-timeseries.ts new file mode 100644 index 0000000000..6df80358af --- /dev/null +++ b/packages/cli/src/lib/formatters/sixel-timeseries.ts @@ -0,0 +1,58 @@ +/** + * Timeseries → sixel chart renderer. + * + * Thin wrapper over the shared chart core (see {@link buildChartModel} and + * {@link rasterizeChart}): builds the resolution-independent model, rasterizes + * it at the caller's pixel resolution, then reuses the existing + * {@link encodeImageToSixel} encoder for a terminal-ready DCS escape sequence. + */ + +import type { TimeseriesResult } from "../../types/dashboard.js"; +import { encodeImageToSixel } from "../sixel-image.js"; +import { buildChartModel, rasterizeChart } from "./chart-core.js"; + +export type RenderSixelOpts = { + /** Maximum pixel width of the rendered chart. */ + maxPixelWidth?: number; + /** Maximum pixel height of the rendered chart. */ + maxPixelHeight?: number; + /** Leave the background transparent instead of filling it. */ + backgroundTransparent?: boolean; +}; + +/** Default chart bitmap dimensions. */ +const DEFAULT_WIDTH = 320; +const DEFAULT_HEIGHT = 120; + +/** + * Render a timeseries result as an inline sixel image. + * + * Returns a DCS sixel escape sequence, or `undefined` when the data is empty + * or the bitmap has no drawable pixels. + */ +export function renderTimeseriesAsSixel( + data: TimeseriesResult, + opts: RenderSixelOpts = {} +): string | undefined { + const { + maxPixelWidth = DEFAULT_WIDTH, + maxPixelHeight = DEFAULT_HEIGHT, + backgroundTransparent = true, + } = opts; + + const model = buildChartModel(data); + if (!model) { + return; + } + + const img = rasterizeChart(model, { + width: maxPixelWidth, + height: maxPixelHeight, + backgroundTransparent, + }); + if (!img) { + return; + } + + return encodeImageToSixel(img, img.width); +} diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts new file mode 100644 index 0000000000..c245d5ad8c --- /dev/null +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -0,0 +1,149 @@ +/** + * Shared chart core tests. + */ + +import { describe, expect, test } from "vitest"; +import { + buildChartModel, + hexToRgb, + rasterizeChart, + SERIES_PALETTE, + seriesColor, +} from "../../../src/lib/formatters/chart-core.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("seriesColor", () => { + test("returns muted gray for the Other bucket", () => { + expect(seriesColor("Other", 3)).toBe("#888888"); + }); + + test("cycles through the palette by index", () => { + expect(seriesColor("a", 0)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", SERIES_PALETTE.length)).toBe(SERIES_PALETTE[0]); + expect(seriesColor("a", 1)).toBe(SERIES_PALETTE[1]); + }); +}); + +describe("hexToRgb", () => { + test("parses six-digit hex", () => { + expect(hexToRgb("#7553FF")).toEqual([0x75, 0x53, 0xff]); + }); + + test("parses shorthand three-digit hex", () => { + expect(hexToRgb("#0f8")).toEqual([0x00, 0xff, 0x88]); + }); +}); + +describe("buildChartModel", () => { + test("returns undefined for empty series", () => { + expect(buildChartModel(makeTimeseries({ series: [] }))).toBeUndefined(); + }); + + test("returns undefined when every series is empty", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }) + ); + expect(model).toBeUndefined(); + }); + + test("builds a single-series, non-stacked model with peak maxVal", () => { + const model = buildChartModel(makeTimeseries()); + expect(model).toBeDefined(); + expect(model?.stacked).toBe(false); + expect(model?.buckets).toBe(4); + expect(model?.maxVal).toBe(30); + }); + + test("builds a stacked model with per-bucket totals as maxVal", () => { + const model = buildChartModel( + makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1, value: 10 }, + { timestamp: 2, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1, value: 5 }, + { timestamp: 2, value: 10 }, + ], + }, + ], + }) + ); + expect(model?.stacked).toBe(true); + expect(model?.buckets).toBe(2); + // Largest per-bucket total is 20 + 10 = 30. + expect(model?.maxVal).toBe(30); + }); +}); + +describe("rasterizeChart", () => { + test("returns a canvas at the requested resolution", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + expect(img).toBeDefined(); + expect(img?.width).toBe(64); + expect(img?.height).toBe(32); + expect(img?.data.length).toBe(64 * 32 * 4); + }); + + test("clamps resolution to a minimum size", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 1, height: 1 }); + expect(img?.width).toBe(16); + expect(img?.height).toBe(8); + }); + + test("draws opaque pixels for bars", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { width: 64, height: 32 }); + let opaque = 0; + for (let i = 3; i < (img?.data.length ?? 0); i += 4) { + if ((img?.data[i] ?? 0) > 0) { + opaque += 1; + } + } + expect(opaque).toBeGreaterThan(0); + }); + + test("fills the background when transparency is off", () => { + const model = buildChartModel(makeTimeseries()); + const img = rasterizeChart(model!, { + width: 32, + height: 16, + backgroundTransparent: false, + }); + // Top-left pixel is above the bars, so it shows the background fill. + expect(img?.data[3]).toBe(255); + }); +}); diff --git a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts new file mode 100644 index 0000000000..db4485db87 --- /dev/null +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -0,0 +1,146 @@ +/** + * Dashboard sixel integration tests. + * + * Stubs `canRenderSixel` and `terminalPixelWidth` so the dashboard formatter + * takes the sixel rendering path deterministically, then verifies that the + * output contains a sixel DCS sequence for eligible timeseries widgets. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type DashboardViewData, + type DashboardViewWidget, + formatDashboardWithData, +} from "../../../src/lib/formatters/dashboard.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"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +function makeWidget( + overrides: Partial = {} +): DashboardViewWidget { + return { + title: "Test Widget", + displayType: "line", + data: makeTimeseries(), + ...overrides, + }; +} + +function makeDashboardData( + overrides: Partial = {} +): DashboardViewData { + return { + id: "12345", + title: "My Dashboard", + period: "24h", + fetchedAt: "2024-01-15T10:30:00Z", + url: "https://sentry.io/organizations/test-org/dashboard/12345/", + environment: ["production"], + widgets: [makeWidget()], + ...overrides, + }; +} + +describe("dashboard sixel integration", () => { + let savedSixelEnv: string | undefined; + + beforeEach(() => { + savedSixelEnv = process.env.SENTRY_DASHBOARD_SIXEL; + process.env.SENTRY_DASHBOARD_SIXEL = "1"; + process.env.SENTRY_PLAIN_OUTPUT = "0"; + vi.spyOn(sixelModule, "canRenderSixel").mockReturnValue(true); + vi.spyOn(sixelModule, "terminalPixelWidth").mockReturnValue(320); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (savedSixelEnv === undefined) { + delete process.env.SENTRY_DASHBOARD_SIXEL; + } else { + process.env.SENTRY_DASHBOARD_SIXEL = savedSixelEnv; + } + }); + + test("renders a sixel DCS sequence for timeseries widgets when enabled", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output).toContain("Sixel Chart"); + }); + + test("uses displayType=timeseries_sixel as an opt-in signal", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Explicit Sixel", + displayType: "timeseries_sixel", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + // Disable the env flag so only the displayType triggers sixel rendering. + delete process.env.SENTRY_DASHBOARD_SIXEL; + + const output = formatDashboardWithData(data); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + expect(output).toContain("Explicit Sixel"); + }); + + test("does not emit sixel for non-timeseries widget types", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 1 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain("Big Number"); + expect(output).toContain("Sixel Chart"); + expect(output).toContain(`${ESC}P`); + expect(output).toContain(`${ESC}\\`); + }); +}); diff --git a/packages/cli/test/lib/formatters/sixel-timeseries.test.ts b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts new file mode 100644 index 0000000000..a68fec3084 --- /dev/null +++ b/packages/cli/test/lib/formatters/sixel-timeseries.test.ts @@ -0,0 +1,109 @@ +/** + * Timeseries → sixel renderer tests. + */ + +import { describe, expect, test } from "vitest"; +import { renderTimeseriesAsSixel } from "../../../src/lib/formatters/sixel-timeseries.js"; +import type { TimeseriesResult } from "../../../src/types/dashboard.js"; + +const ESC = "\x1b"; + +function makeTimeseries( + overrides: Partial = {} +): TimeseriesResult { + return { + type: "timeseries", + series: [ + { + label: "count()", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + { timestamp: 1_700_000_120, value: 15 }, + { timestamp: 1_700_000_180, value: 30 }, + ], + }, + ], + ...overrides, + }; +} + +describe("renderTimeseriesAsSixel", () => { + test("returns undefined when there are no series", () => { + const data = makeTimeseries({ series: [] }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("returns undefined when all series are empty", () => { + const data = makeTimeseries({ + series: [ + { label: "a", values: [] }, + { label: "b", values: [] }, + ], + }); + expect(renderTimeseriesAsSixel(data)).toBeUndefined(); + }); + + test("emits a DCS sixel sequence for a single series", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("emits a DCS sixel sequence for stacked multi-series", () => { + const data = makeTimeseries({ + series: [ + { + label: "alpha", + values: [ + { timestamp: 1_700_000_000, value: 10 }, + { timestamp: 1_700_000_060, value: 20 }, + ], + }, + { + label: "beta", + values: [ + { timestamp: 1_700_000_000, value: 5 }, + { timestamp: 1_700_000_060, value: 10 }, + ], + }, + ], + }); + const sixel = renderTimeseriesAsSixel(data, { + maxPixelWidth: 64, + maxPixelHeight: 32, + }); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); + + test("applies background fill when requested", () => { + const data = makeTimeseries(); + const transparent = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: true, + }); + const opaque = renderTimeseriesAsSixel(data, { + maxPixelWidth: 32, + maxPixelHeight: 16, + backgroundTransparent: false, + }); + expect(transparent).toBeDefined(); + expect(opaque).toBeDefined(); + }); + + test("uses sensible defaults for missing options", () => { + const data = makeTimeseries(); + const sixel = renderTimeseriesAsSixel(data); + expect(sixel).toBeDefined(); + expect(sixel).toContain(`${ESC}P`); + expect(sixel).toContain(`${ESC}\\`); + }); +}); From 18f6f3f2d0d7f4d5b4ba67bc20bfe8aa652b95dd Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 19 Aug 2026 07:34:30 +0000 Subject: [PATCH 2/5] fix(dashboard): render sixel widgets as standalone blocks, not in-grid Address review feedback on the sixel timeseries rendering: - Pull sixel-eligible widgets out of the side-by-side framebuffer and render them full-width after the character grid. A DCS image advances the cursor by many rows, so composing it into a shared terminal row trampled the widget's own borders and any neighbor on those rows. - Exclude categorical_bar from sixel: the chart core only models time-bucket columns, so categorical widgets keep the ASCII per-category renderer instead of being drawn as the wrong viz. - Downsample dense series to the pixel budget in rasterizeChart, so ranges with more buckets than ~half the canvas width no longer clip their tail off-canvas (mirrors the ASCII sparkline path). - Use the shared EscapeType for the local esc state in fitToWidth. Adds tests for the categorical exclusion, standalone-block layout, and dense-series downsampling. --- packages/cli/src/lib/formatters/chart-core.ts | 32 +++++++- packages/cli/src/lib/formatters/dashboard.ts | 82 ++++++++++++++----- .../test/lib/formatters/chart-core.test.ts | 27 ++++++ .../dashboard-sixel-integration.test.ts | 47 +++++++++++ 4 files changed, 164 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/lib/formatters/chart-core.ts b/packages/cli/src/lib/formatters/chart-core.ts index d7e18da429..63bc6630b8 100644 --- a/packages/cli/src/lib/formatters/chart-core.ts +++ b/packages/cli/src/lib/formatters/chart-core.ts @@ -11,6 +11,7 @@ import type { TimeseriesResult } from "../../types/dashboard.js"; import type { DecodedImage } from "../sixel-image.js"; +import { downsample } from "./sparkline.js"; /** * Chart color palette based on Sentry's categorical chart hues. @@ -157,18 +158,41 @@ export function rasterizeChart( return; } + // Each column needs at least a 1px bar plus a 1px gap, so more buckets than + // ~half the canvas width would push later columns off-canvas and clip them. + // Downsample to fit, mirroring what the ASCII sparkline path already does. + const fitted = fitModelToWidth(model, width); + const img = createCanvas(width, height, opts.backgroundTransparent ?? true); - const layout = computeBarLayout(width, model.buckets); + const layout = computeBarLayout(width, fitted.buckets); - if (model.stacked) { - drawStackedColumns(img, model, height, layout); + if (fitted.stacked) { + drawStackedColumns(img, fitted, height, layout); } else { - drawBars(img, model, height, layout); + drawBars(img, fitted, height, layout); } return img; } +/** + * Downsample a model's series so the bucket count fits the canvas: with a 1px + * bar and 1px gap each column needs ~2px, so cap buckets at `width / 2`. + * Returns the model unchanged when it already fits. + */ +function fitModelToWidth(model: ChartModel, width: number): ChartModel { + const maxBuckets = Math.max(1, Math.floor(width / 2)); + if (model.buckets <= maxBuckets) { + return model; + } + const series = model.series.map((s) => ({ + label: s.label, + values: downsample(s.values, maxBuckets), + })); + const buckets = Math.max(...series.map((s) => s.values.length)); + return { ...model, series, buckets }; +} + /** Create an RGBA canvas, optionally transparent. */ function createCanvas( width: number, diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 7e4311ac66..c1a6829693 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1533,24 +1533,6 @@ function renderContentLines(opts: { switch (data.type) { case "timeseries": { - // Opt-in sixel image rendering for timeseries widgets. - const env = getEnv(); - if ( - !isPlainOutput() && - (env.SENTRY_DASHBOARD_SIXEL === "1" || - widget.displayType === "timeseries_sixel") && - canRenderSixel() - ) { - const pixelBudget = terminalPixelWidth(); - const sixel = renderTimeseriesAsSixel(data, { - maxPixelWidth: pixelBudget ?? innerWidth * 8, - maxPixelHeight: contentHeight * 12, - }); - if (sixel) { - return [sixel]; - } - } - if (widget.displayType === "categorical_bar") { return renderVerticalBarsContent(data, { innerWidth, contentHeight }); } @@ -1697,7 +1679,7 @@ function fitToWidth(line: string, targetWidth: number): string { // Truncate: walk characters, tracking visible width let result = ""; let width = 0; - const esc = { type: "none" as "none" | "start" | "csi" | "osc" }; + const esc: { type: EscapeType } = { type: "none" }; for (const ch of line) { if (advanceEscape(esc, ch, result)) { result += ch; @@ -1862,11 +1844,71 @@ export function formatDashboardWithData(data: DashboardViewData): string { const termWidth = getTermWidth(); const lines: string[] = []; lines.push(...renderHeader(data, termWidth)); - lines.push(...renderGrid(data.widgets, termWidth)); + + // Sixel widgets can't be composed into the side-by-side framebuffer: a DCS + // image advances the cursor by many rows, which would trample the widget's + // own borders and any neighbor sharing its grid rows. Render them full-width + // and stacked, after the character grid, and keep the grid for the rest. + const sixelWidgets = data.widgets.filter(isSixelEligible); + const gridWidgets = sixelWidgets.length + ? data.widgets.filter((w) => !isSixelEligible(w)) + : data.widgets; + + if (gridWidgets.length > 0) { + lines.push(...renderGrid(gridWidgets, termWidth)); + } + for (const w of sixelWidgets) { + lines.push(...renderSixelWidget(w, termWidth)); + } + lines.push(""); return lines.join("\n"); } +/** + * Whether a widget should render as an inline sixel image: opt-in is on, the + * data is a plain (non-categorical) timeseries, and the terminal supports + * sixel. Categorical bars are excluded — the chart core only models + * time-bucket columns, so they keep the ASCII per-category renderer. + */ +function isSixelEligible(widget: DashboardViewWidget): boolean { + if ( + widget.data.type !== "timeseries" || + widget.displayType === "categorical_bar" + ) { + return false; + } + const env = getEnv(); + const optedIn = + env.SENTRY_DASHBOARD_SIXEL === "1" || + widget.displayType === "timeseries_sixel"; + return optedIn && !isPlainOutput() && canRenderSixel(); +} + +/** + * Render a single sixel widget as a full-width block: a title line followed by + * the inline image. Falls back to the normal bordered character rendering when + * the image can't be produced (empty data, no drawable pixels). + */ +function renderSixelWidget( + widget: DashboardViewWidget, + termWidth: number +): string[] { + if (widget.data.type !== "timeseries") { + return renderWidgetLines(widget, termWidth); + } + const pixelBudget = terminalPixelWidth(); + const sixel = renderTimeseriesAsSixel(widget.data, { + maxPixelWidth: pixelBudget ?? termWidth * 8, + maxPixelHeight: 2 * LINES_PER_UNIT * 12, + }); + if (!sixel) { + return renderWidgetLines(widget, termWidth); + } + const title = isPlainOutput() ? widget.title : chalk.bold(widget.title); + return ["", title, sixel]; +} + // --------------------------------------------------------------------------- // HumanRenderer factory (supports --refresh mode) // --------------------------------------------------------------------------- diff --git a/packages/cli/test/lib/formatters/chart-core.test.ts b/packages/cli/test/lib/formatters/chart-core.test.ts index c245d5ad8c..8f4cef672c 100644 --- a/packages/cli/test/lib/formatters/chart-core.test.ts +++ b/packages/cli/test/lib/formatters/chart-core.test.ts @@ -146,4 +146,31 @@ describe("rasterizeChart", () => { // Top-left pixel is above the bars, so it shows the background fill. expect(img?.data[3]).toBe(255); }); + + test("downsamples dense series so late buckets stay on canvas", () => { + // Far more buckets than half the canvas width: without downsampling the + // rising tail would be clipped off the right edge. Put all the signal in + // the last quarter of the range so a clipped render would be near-empty. + const values = Array.from({ length: 400 }, (_, i) => ({ + timestamp: 1_700_000_000 + i * 60, + value: i < 300 ? 0 : i, + })); + const model = buildChartModel( + makeTimeseries({ series: [{ label: "c", values }] }) + ); + const width = 64; + const img = rasterizeChart(model!, { width, height: 32 }); + + // Count opaque pixels in the right quarter — the tail must survive. + let rightOpaque = 0; + const data = img?.data ?? new Uint8Array(); + for (let y = 0; y < 32; y++) { + for (let x = Math.floor(width * 0.75); x < width; x++) { + if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) { + rightOpaque += 1; + } + } + } + expect(rightOpaque).toBeGreaterThan(0); + }); }); 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 db4485db87..096c923c02 100644 --- a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -143,4 +143,51 @@ describe("dashboard sixel integration", () => { expect(output).toContain(`${ESC}P`); expect(output).toContain(`${ESC}\\`); }); + + test("categorical_bar widgets keep the ASCII renderer, not sixel", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Categorical", + displayType: "categorical_bar", + layout: { x: 0, y: 0, w: 6, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + expect(output).toContain("Categorical"); + // The chart core has no categorical mode, so these must not be rasterized. + expect(output).not.toContain(`${ESC}P`); + }); + + test("sixel widgets render as a standalone block, keeping the grid intact", () => { + const data = makeDashboardData({ + widgets: [ + makeWidget({ + title: "Big Number", + displayType: "big_number", + data: { type: "scalar", value: 42 }, + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + makeWidget({ + title: "Sixel Chart", + displayType: "line", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + ], + }); + + const output = formatDashboardWithData(data); + const lines = output.split("\n"); + // The DCS image must not share a terminal row with the neighboring widget's + // borders — the sixel line carries no box-drawing characters. + const sixelLine = lines.find((l) => l.includes(`${ESC}P`)); + expect(sixelLine).toBeDefined(); + expect(sixelLine).not.toContain("│"); + expect(sixelLine).not.toContain("─"); + // The character grid (big-number widget) still renders above the image. + expect(output).toContain("Big Number"); + expect(output).toContain("Sixel Chart"); + }); }); From de42304660e73fe074756e7f9b21c31b4d644a79 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 19 Aug 2026 07:38:04 +0000 Subject: [PATCH 3/5] fix(dashboard): pack remaining grid widgets after sixel extraction When sixel-eligible widgets are stripped out of the framebuffer, the remaining widgets kept their original `layout.y`, so `renderGrid` still allocated blank bands for the removed rows and the sixel images were always appended after the whole grid (changing vertical order). Compress `layout.y` values by dense rank while preserving relative order within the same original band, so the grid packs contiguously from y=0 with no holes and no reordering of left/right widgets on the same row. --- packages/cli/src/lib/formatters/dashboard.ts | 37 ++++++++++++++++++- .../dashboard-sixel-integration.test.ts | 6 +-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index c1a6829693..e598e654e3 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1855,7 +1855,8 @@ export function formatDashboardWithData(data: DashboardViewData): string { : data.widgets; if (gridWidgets.length > 0) { - lines.push(...renderGrid(gridWidgets, termWidth)); + const packed = packGridY(gridWidgets); + lines.push(...renderGrid(packed, termWidth)); } for (const w of sixelWidgets) { lines.push(...renderSixelWidget(w, termWidth)); @@ -1865,6 +1866,40 @@ export function formatDashboardWithData(data: DashboardViewData): string { return lines.join("\n"); } +/** + * Return a shallow copy of the widgets with their `layout.y` renumbered so + * the remaining widgets pack contiguously from y=0 without blank bands. + * Widgets without a layout are left untouched. Relative order within the + * same original y-band is preserved (important for left/right widgets on + * the same row). + */ +function packGridY( + widgets: DashboardViewWidget[] +): DashboardViewWidget[] { + const ys = Array.from( + new Set( + widgets + .map((w) => w.layout?.y) + .filter((y): y is number => typeof y === "number") + ) + ).sort((a, b) => a - b); + if (ys.length === 0) { + return widgets; + } + const rank = new Map(ys.map((y, i) => [y, i])); + + return widgets.map((w) => { + if (!w.layout) { + return w; + } + const r = rank.get(w.layout.y); + if (r === undefined) { + return w; + } + return { ...w, layout: { ...w.layout, y: r } }; + }); +} + /** * Whether a widget should render as an inline sixel image: opt-in is on, the * data is a plain (non-categorical) timeseries, and the terminal supports 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 096c923c02..93a779a586 100644 --- a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -180,13 +180,13 @@ describe("dashboard sixel integration", () => { const output = formatDashboardWithData(data); const lines = output.split("\n"); - // The DCS image must not share a terminal row with the neighboring widget's - // borders — the sixel line carries no box-drawing characters. + // The DCS image must not share a terminal row with any bordered widget. const sixelLine = lines.find((l) => l.includes(`${ESC}P`)); expect(sixelLine).toBeDefined(); expect(sixelLine).not.toContain("│"); expect(sixelLine).not.toContain("─"); - // The character grid (big-number widget) still renders above the image. + // The character grid (big-number widget) still renders; the sixel image + // appears after the grid (full-width, no hole in the packed layout). expect(output).toContain("Big Number"); expect(output).toContain("Sixel Chart"); }); From 0fad1124374f9671e84a5d9f01cc14729315f6db Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 19 Aug 2026 07:39:36 +0000 Subject: [PATCH 4/5] fix(dashboard): packGridY must respect widget height The previous dense-rank compression only renumbered `y` values and ignored `layout.h`. Widgets that were stacked (`y_new = y_prev + h_prev`) collapsed to consecutive integers and overlapped. Pack by accumulating the tallest height in each original y-band so the next band starts immediately below the previous tallest widget. --- packages/cli/src/lib/formatters/dashboard.ts | 39 ++++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index e598e654e3..26a8acb040 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1876,27 +1876,42 @@ export function formatDashboardWithData(data: DashboardViewData): string { function packGridY( widgets: DashboardViewWidget[] ): DashboardViewWidget[] { - const ys = Array.from( - new Set( - widgets - .map((w) => w.layout?.y) - .filter((y): y is number => typeof y === "number") - ) - ).sort((a, b) => a - b); - if (ys.length === 0) { + const withLayout = widgets.filter((w) => w.layout); + if (withLayout.length === 0) { return widgets; } - const rank = new Map(ys.map((y, i) => [y, i])); + // Stable sort by original y so relative vertical order is preserved. + const sorted = [...withLayout].sort( + (a, b) => (a.layout?.y ?? 0) - (b.layout?.y ?? 0) + ); + + let nextY = 0; + const newY = new Map(); + for (const w of sorted) { + if (newY.has(w)) { + continue; + } + // All widgets that share the same original y-band get the same new y. + const bandY = w.layout!.y; + const band = sorted.filter((ww) => ww.layout!.y === bandY); + for (const b of band) { + newY.set(b, nextY); + } + // Advance past the tallest widget in the band so the next band starts + // immediately below (respecting each widget's original h). + const maxH = Math.max(...band.map((b) => b.layout!.h ?? 1)); + nextY += maxH; + } return widgets.map((w) => { if (!w.layout) { return w; } - const r = rank.get(w.layout.y); - if (r === undefined) { + const ny = newY.get(w); + if (ny === undefined) { return w; } - return { ...w, layout: { ...w.layout, y: r } }; + return { ...w, layout: { ...w.layout, y: ny } }; }); } From 4a1ee6c78dc602db53d29747a2e0739e144a69ec Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Wed, 19 Aug 2026 07:41:37 +0000 Subject: [PATCH 5/5] fix(dashboard): guard packGridY against staggered layouts Detect widgets whose start y lies inside another widget's span (the common tall-left + short-right pattern). When the layout is staggered, skip packing and keep the original y values so the grid is rendered exactly as the dashboard author laid it out. Staggered packing is out of scope for this opt-in experimental path. --- packages/cli/src/lib/formatters/dashboard.ts | 31 ++++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 26a8acb040..b51d562a4b 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -1872,6 +1872,10 @@ export function formatDashboardWithData(data: DashboardViewData): string { * Widgets without a layout are left untouched. Relative order within the * same original y-band is preserved (important for left/right widgets on * the same row). + * + * Packing is skipped for staggered layouts (widgets whose start y lies + * inside another widget's span) because correctly re-computing y for + * overlapping columns is out of scope for this opt-in experimental path. */ function packGridY( widgets: DashboardViewWidget[] @@ -1880,6 +1884,30 @@ function packGridY( if (withLayout.length === 0) { return widgets; } + + // Detect staggered starts: a widget whose y is not equal to any other + // widget's y + h (for the tallest h at that y). + const bands = new Map(); // y -> max h at that y + for (const w of withLayout) { + const y = w.layout!.y; + const h = w.layout!.h ?? 1; + bands.set(y, Math.max(bands.get(y) ?? 0, h)); + } + let maxEnd = 0; + let staggered = false; + for (const [y, h] of Array.from(bands.entries()).sort( + (a, b) => a[0] - b[0] + )) { + if (y < maxEnd) { + staggered = true; + break; + } + maxEnd = y + h; + } + if (staggered) { + return widgets; // leave original y untouched + } + // Stable sort by original y so relative vertical order is preserved. const sorted = [...withLayout].sort( (a, b) => (a.layout?.y ?? 0) - (b.layout?.y ?? 0) @@ -1891,14 +1919,11 @@ function packGridY( if (newY.has(w)) { continue; } - // All widgets that share the same original y-band get the same new y. const bandY = w.layout!.y; const band = sorted.filter((ww) => ww.layout!.y === bandY); for (const b of band) { newY.set(b, nextY); } - // Advance past the tallest widget in the band so the next band starts - // immediately below (respecting each widget's original h). const maxH = Math.max(...band.map((b) => b.layout!.h ?? 1)); nextY += maxH; }