diff --git a/apps/cli-docs/src/fragments/commands/api.md b/apps/cli-docs/src/fragments/commands/api.md index 570477fb4..7f9119d40 100644 --- a/apps/cli-docs/src/fragments/commands/api.md +++ b/apps/cli-docs/src/fragments/commands/api.md @@ -72,6 +72,6 @@ Endpoints that return binary data — image attachments, minidumps, debug files sentry api "projects/my-org/my-project/events/EVENT_ID/attachments/ATTACHMENT_ID/?download=1" > screenshot.png ``` -When the response is a PNG or JPEG image **and** you're on a sixel-capable terminal, the image is rendered inline instead of dumping raw bytes into your session. Redirecting or piping stdout always keeps the raw bytes. Set `SENTRY_NO_SIXEL=1` to disable inline rendering. +When the response is a PNG or JPEG image **and** you're on a graphics-capable terminal, the image is rendered inline instead of dumping raw bytes into your session. Terminals that speak the newer kitty graphics protocol (kitty, WezTerm, Ghostty, recent Konsole) are used in preference to sixel, which remains the fallback for older terminals. Redirecting or piping stdout always keeps the raw bytes. Set `SENTRY_NO_GRAPHICS=1` (or run `sentry cli defaults graphics off`) to disable inline rendering; `SENTRY_NO_SIXEL` is still honored as a deprecated alias. For full API documentation, see the [Sentry API Reference](https://docs.sentry.io/api/). diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index 2e896102f..6977cc957 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -34,9 +34,6 @@ sentry dashboard view 12345 # Auto-refresh every 30 seconds sentry dashboard view "Backend Performance" --refresh 30 -# Render as a sixel image (for terminals that support sixel graphics) -sentry dashboard view "Backend Performance" --sixel - # Open in browser sentry dashboard view 12345 -w ``` diff --git a/packages/cli/lint-rules/prefer-paginate-helper.grit b/packages/cli/lint-rules/prefer-paginate-helper.grit index faf0933ac..ea8352114 100644 --- a/packages/cli/lint-rules/prefer-paginate-helper.grit +++ b/packages/cli/lint-rules/prefer-paginate-helper.grit @@ -3,7 +3,9 @@ file($name, $body) where { $name <: not r".*infrastructure\.ts$", $body <: contains or { `autoPaginate($fetch, $bound)` as $match where { $bound <: `limit` }, - `autoPaginate($fetch, $bound, $cursor)` as $match where { $bound <: `limit` } + `autoPaginate($fetch, $bound, $cursor)` as $match where { + $bound <: `limit` + } }, register_diagnostic(span=$match, message="Use paginate() from infrastructure.js instead of wiring autoPaginate() with a raw limit. It centralizes the per_page cap and limit+cursor threading (see #1473).") } 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 0667023f8..2fce8a508 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,7 +40,6 @@ View a dashboard **Flags:** - `-w, --web - Open in browser` -- `-s, --sixel - Render the dashboard as a sixel image` - `-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"` @@ -57,9 +56,6 @@ sentry dashboard view 12345 # Auto-refresh every 30 seconds sentry dashboard view "Backend Performance" --refresh 30 -# Render as a sixel image (for terminals that support sixel graphics) -sentry dashboard view "Backend Performance" --sixel - # Open in browser sentry dashboard view 12345 -w ``` diff --git a/packages/cli/script/check-env-coverage.ts b/packages/cli/script/check-env-coverage.ts index b7ff76f4f..dfad66222 100644 --- a/packages/cli/script/check-env-coverage.ts +++ b/packages/cli/script/check-env-coverage.ts @@ -59,10 +59,6 @@ const INTERNAL_ENV_VARS = new Map([ "SENTRY_SCAN_DISABLE_WORKERS", "internal scanner performance tuning, not user-facing", ], - [ - "SENTRY_DASHBOARD_SIXEL", - "internal sixel debug toggle; users use the --sixel flag", - ], [ "SENTRY_CLI_INTEGRATION_TEST_VERSION_OVERRIDE", "integration-test-only version override", diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 892d5f2b5..11803f751 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -15,9 +15,14 @@ import { OutputError, ValidationError } from "../lib/errors.js"; import { filterFields } from "../lib/formatters/json.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { validateEndpoint } from "../lib/input-validation.js"; +import { imageBytesToKitty } from "../lib/kitty-image.js"; import { logger } from "../lib/logger.js"; import { getDefaultSdkConfig } from "../lib/sentry-client.js"; -import { canRenderSixel, terminalPixelWidth } from "../lib/sixel.js"; +import { + canRenderKitty, + canRenderSixel, + terminalPixelWidth, +} from "../lib/sixel.js"; import { imageBytesToSixel } from "../lib/sixel-image.js"; const log = logger.withTag("api"); @@ -1299,36 +1304,43 @@ export function resolveApiResponseOutput( /** * For a binary response headed to an interactive TTY, either render it inline - * as a sixel image (when it's a supported image format and the terminal - * advertises sixel support) or warn that raw bytes are being dumped. + * as an image (when it's a supported format and the terminal advertises a + * graphics protocol) or warn that raw bytes are being dumped. Newer terminals + * that speak the kitty protocol are preferred; sixel is the fallback. * * @param body - The raw response bytes. * @param headers - Response headers (Content-Type is used as a decode hint). - * @param allowSixel - Whether inline sixel rendering is permitted. Pass `false` - * in `--json` mode: the raw bytes still stream out unchanged, but injecting a - * sixel escape sequence would corrupt machine-readable output. The raw-dump - * warning still fires so the user knows their terminal is about to be flooded. - * @returns A sixel escape string to write instead of the raw bytes, or + * @param allowGraphics - Whether inline image rendering is permitted. Pass + * `false` in `--json` mode: the raw bytes still stream out unchanged, but + * injecting a graphics escape sequence would corrupt machine-readable output. + * The raw-dump warning still fires so the user knows their terminal is about + * to be flooded. + * @returns A graphics escape string to write instead of the raw bytes, or * `undefined` to fall through to the raw-byte behavior. * @internal Exported for testing */ export function resolveBinaryTtyOutput( body: Uint8Array, headers: Headers, - allowSixel = true + allowGraphics = true ): string | undefined { - if (allowSixel && canRenderSixel()) { + if (allowGraphics) { // Cap the rendered width to the terminal's pixel budget so a wide image // doesn't overflow the columns and garble the session. Falls back to the // encoder's default when the terminal didn't report a cell width. const maxWidth = terminalPixelWidth(); - const sixel = imageBytesToSixel( - body, - headers.get("content-type"), - maxWidth - ); - if (sixel) { - return sixel; + const contentType = headers.get("content-type"); + if (canRenderKitty()) { + const kitty = imageBytesToKitty(body, contentType, maxWidth); + if (kitty) { + return kitty; + } + } + if (canRenderSixel()) { + const sixel = imageBytesToSixel(body, contentType, maxWidth); + if (sixel) { + return sixel; + } } } diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 4b69b713f..c3cdc55df 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -27,6 +27,7 @@ import { getDefaultOrganization, getDefaultProject, getDefaultUrl, + getGraphicsPreference, getTelemetryPreference, setAgentSkillsPreference, setDefaultCaCert, @@ -34,6 +35,7 @@ import { setDefaultOrganization, setDefaultProject, setDefaultUrl, + setGraphicsPreference, setTelemetryPreference, } from "../../lib/db/defaults.js"; import { ValidationError } from "../../lib/errors.js"; @@ -59,6 +61,7 @@ type DefaultKey = | "project" | "telemetry" | "agent-skills" + | "graphics" | "url" | "headers" | "ca-cert"; @@ -140,6 +143,29 @@ const DEFAULTS_REGISTRY: Record = { }, clear: () => setAgentSkillsPreference(null), }, + graphics: { + get: () => { + const pref = getGraphicsPreference(); + if (pref === true) { + return "on"; + } + if (pref === false) { + return "off"; + } + return null; + }, + set: (value) => { + const parsed = parseBoolValue(value); + if (parsed === null) { + throw new ValidationError( + `Invalid graphics value: '${value}'. Use on/off, yes/no, true/false, or 1/0.`, + "graphics" + ); + } + setGraphicsPreference(parsed); + }, + clear: () => setGraphicsPreference(null), + }, url: { get: getDefaultUrl, set: (value) => { @@ -254,6 +280,7 @@ export const defaultsCommand = buildCommand({ "sentry cli defaults project my-proj # Set default project\n" + "sentry cli defaults telemetry off # Disable telemetry\n" + "sentry cli defaults agent-skills off # Stop installing agent skills on upgrade\n" + + "sentry cli defaults graphics off # Disable inline terminal images\n" + "sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" + "sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" + "sentry cli defaults ca-cert /path/to/ca.pem # Trust a custom CA certificate\n" + @@ -267,6 +294,7 @@ export const defaultsCommand = buildCommand({ "| `project` | Default project slug |\n" + "| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" + "| `agent-skills` | Install agent skills on setup/upgrade (on/off, yes/no, true/false, 1/0) |\n" + + "| `graphics` | Render images inline on capable terminals (on/off, yes/no, true/false, 1/0) |\n" + "| `url` | Sentry instance URL (for self-hosted installations) |\n" + "| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |\n" + "| `ca-cert` | Path to PEM file with custom CA certificates (for corporate proxies) |", @@ -323,7 +351,7 @@ export const defaultsCommand = buildCommand({ guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { const confirmed = await log.prompt( - "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills). Continue?", + "This will clear all defaults (organization, project, telemetry, URL, headers, ca-cert, agent-skills, graphics). Continue?", { type: "confirm" } ); if (confirmed !== true) { diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 1806fa469..f8d1bfeff 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -54,7 +54,6 @@ type ViewFlags = { readonly period?: TimeRange; readonly json: boolean; readonly fields?: string[]; - readonly sixel: boolean; }; /** @@ -108,7 +107,7 @@ function buildViewData( }, widgetResults: Map, widgets: DashboardWidget[], - opts: { period: string; url: string; sixel: boolean } + opts: { period: string; url: string } ): DashboardViewData { return { id: dashboard.id, @@ -118,7 +117,6 @@ function buildViewData( url: opts.url, dateCreated: dashboard.dateCreated, environment: dashboard.environment, - sixel: opts.sixel, widgets: widgets.map((w, i) => ({ title: w.title, displayType: w.displayType, @@ -190,11 +188,6 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, - sixel: { - kind: "boolean", - brief: "Render the dashboard as a sixel image", - default: false, - }, fresh: FRESH_FLAG, refresh: { kind: "parsed", @@ -213,7 +206,6 @@ export const viewCommand = buildCommand({ aliases: { ...FRESH_ALIASES, w: "web", - s: "sixel", r: "refresh", t: "period", }, @@ -294,7 +286,6 @@ export const viewCommand = buildCommand({ const viewData = buildViewData(dashboard, widgetData, widgets, { period: formatTimeRangeFlag(timeRange), url, - sixel: flags.sixel, }); if (!isFirstRender) { @@ -326,7 +317,6 @@ export const viewCommand = buildCommand({ buildViewData(dashboard, widgetData, widgets, { period: formatTimeRangeFlag(timeRange), url, - sixel: flags.sixel, }) ); return { hint: `Dashboard: ${url}` }; diff --git a/packages/cli/src/lib/db/defaults.ts b/packages/cli/src/lib/db/defaults.ts index 95ba765e9..ff633dd44 100644 --- a/packages/cli/src/lib/db/defaults.ts +++ b/packages/cli/src/lib/db/defaults.ts @@ -7,6 +7,7 @@ * - `defaults.telemetry` — telemetry preference (`"on"` / `"off"`) * - `defaults.url` — Sentry instance URL (for self-hosted) * - `defaults.agent-skills` — agent skill install preference (`"on"` / `"off"`) + * - `defaults.graphics` — inline terminal graphics preference (`"on"` / `"off"`) */ import { getDatabase } from "./index.js"; @@ -19,6 +20,7 @@ const DEFAULTS_URL = "defaults.url"; const DEFAULTS_HEADERS = "defaults.headers"; const DEFAULTS_CA_CERT = "defaults.ca-cert"; const DEFAULTS_AGENT_SKILLS = "defaults.agent-skills"; +const DEFAULTS_GRAPHICS = "defaults.graphics"; /** All metadata keys used for defaults (for bulk operations) */ const ALL_DEFAULTS_KEYS = [ @@ -29,6 +31,7 @@ const ALL_DEFAULTS_KEYS = [ DEFAULTS_HEADERS, DEFAULTS_CA_CERT, DEFAULTS_AGENT_SKILLS, + DEFAULTS_GRAPHICS, ]; /** State of all persistent defaults */ @@ -47,6 +50,8 @@ export type DefaultsState = { "ca-cert": string | null; /** Agent skill install preference: "on", "off", or null (= default enabled) */ "agent-skills": "on" | "off" | null; + /** Inline terminal graphics preference: "on", "off", or null (= default enabled) */ + graphics: "on" | "off" | null; }; /** Parse a raw "on" / "off" metadata value to a typed "on" | "off" | null. */ @@ -116,6 +121,25 @@ export function getAgentSkillsPreference(): boolean | undefined { return; } +/** + * Get the persistent inline-graphics preference. + * + * @returns `true` if explicitly enabled, `false` if explicitly disabled, + * `undefined` if no preference is stored (callers should default to enabled) + */ +export function getGraphicsPreference(): boolean | undefined { + const db = getDatabase(); + const m = getMetadata(db, [DEFAULTS_GRAPHICS]); + const val = m.get(DEFAULTS_GRAPHICS); + if (val === "on") { + return true; + } + if (val === "off") { + return false; + } + return; +} + /** Get the default Sentry instance URL, or null if not set. */ export function getDefaultUrl(): string | null { const db = getDatabase(); @@ -158,6 +182,7 @@ export function getAllDefaults(): DefaultsState { headers: m.get(DEFAULTS_HEADERS) ?? null, "ca-cert": m.get(DEFAULTS_CA_CERT) ?? null, "agent-skills": parseOnOffValue(m.get(DEFAULTS_AGENT_SKILLS)), + graphics: parseOnOffValue(m.get(DEFAULTS_GRAPHICS)), }; } @@ -211,6 +236,19 @@ export function setAgentSkillsPreference(enabled: boolean | null): void { } } +/** + * Set or clear the persistent inline-graphics preference. + * Pass `null` to remove the preference (callers will default to enabled). + */ +export function setGraphicsPreference(enabled: boolean | null): void { + const db = getDatabase(); + if (enabled === null) { + clearMetadata(db, [DEFAULTS_GRAPHICS]); + } else { + setMetadata(db, { [DEFAULTS_GRAPHICS]: enabled ? "on" : "off" }); + } +} + /** Set or clear the default Sentry instance URL. Pass `null` to clear. */ export function setDefaultUrl(url: string | null): void { const db = getDatabase(); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 38130ecf5..cd24b9dcb 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -199,10 +199,16 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Force plain text output (no colors or ANSI formatting). Takes precedence over `NO_COLOR`.", example: "1", }, + { + name: "SENTRY_NO_GRAPHICS", + description: + "Disable inline terminal graphics (kitty and sixel) on terminals that support them; the banner falls back to block art, and image attachments printed by `sentry api` are written as raw bytes instead of rendered inline. You can also set this persistently with `sentry cli defaults graphics off`.", + example: "1", + }, { name: "SENTRY_NO_SIXEL", description: - "Disable sixel graphics on terminals that support it; the banner falls back to block art, and image attachments printed by `sentry api` are written as raw bytes instead of rendered inline.", + "Deprecated alias for `SENTRY_NO_GRAPHICS`. Still honored for backward compatibility.", example: "1", }, { diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index 406418bd3..673150d15 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -19,8 +19,9 @@ import type { TimeseriesResult, WidgetDataResult, } from "../../types/dashboard.js"; -import { getEnv } from "../env.js"; +import { encodeImageToKitty } from "../kitty-image.js"; import { + canRenderKitty, canRenderSixel, terminalPixelHeight, terminalPixelWidth, @@ -46,8 +47,6 @@ export type DashboardViewData = { url: string; dateCreated?: string; environment?: string[]; - /** Per-invocation sixel opt-in from `dashboard view --sixel`. */ - sixel?: boolean; widgets: DashboardViewWidget[]; }; @@ -1871,21 +1870,17 @@ export function formatDashboardWithData(data: DashboardViewData): string { } /** - * Render the complete dashboard as one sixel canvas only when the terminal - * exposes both cell dimensions. A sixel-only feature must never partially - * replace the framebuffer: unavailable geometry always returns the complete - * established character rendering. + * Render the complete dashboard as one graphics canvas (kitty when available, + * otherwise sixel) when graphics are enabled and the terminal exposes both cell + * dimensions. Never partially replaces the framebuffer: unavailable geometry + * always returns the complete established character rendering. */ function renderCompleteDashboardAsSixel( data: DashboardViewData, termWidth: number | undefined ): string | undefined { - const env = getEnv(); - const optedIn = - data.sixel === true || - env.SENTRY_DASHBOARD_SIXEL === "1" || - data.widgets.some((widget) => widget.displayType === "timeseries_sixel"); - if (!(optedIn && termWidth) || isPlainOutput() || !canRenderSixel()) { + const canKitty = canRenderKitty(); + if (!termWidth || isPlainOutput() || !(canKitty || canRenderSixel())) { return; } const pixelWidth = terminalPixelWidth(termWidth); @@ -1908,6 +1903,9 @@ function renderCompleteDashboardAsSixel( contentHeight, }); }, + encodeImage: canKitty + ? (image) => encodeImageToKitty(image, image.width, true) + : undefined, }); } diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index c85af5e00..b59f50a4d 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -2507,6 +2507,7 @@ const DEFAULT_LABELS: Record = { project: "Project", telemetry: "Telemetry", "agent-skills": "Agent Skills", + graphics: "Graphics", url: "URL", headers: "Headers", "ca-cert": "CA Certificate", @@ -2532,6 +2533,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { const notSet = colorTag("muted", "not set"); const telLabel = d.telemetry ?? "on (default)"; const agentSkillsLabel = d["agent-skills"] ?? "on (default)"; + const graphicsLabel = d.graphics ?? "on (default)"; return [ ["Organization", d.organization ? safeCodeSpan(d.organization) : notSet], @@ -2541,6 +2543,7 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { `${escapeMarkdownInline(String(telLabel))}${telemetryOverrideNote(data.telemetryEffective)}`, ], ["Agent Skills", escapeMarkdownInline(String(agentSkillsLabel))], + ["Graphics", escapeMarkdownInline(String(graphicsLabel))], ["URL", d.url ? safeCodeSpan(d.url) : notSet], ["Headers", d.headers ? safeCodeSpan(d.headers) : notSet], ["CA Certificate", d["ca-cert"] ? safeCodeSpan(d["ca-cert"]) : notSet], diff --git a/packages/cli/src/lib/formatters/sixel-dashboard.ts b/packages/cli/src/lib/formatters/sixel-dashboard.ts index 0306f548a..0f417c16d 100644 --- a/packages/cli/src/lib/formatters/sixel-dashboard.ts +++ b/packages/cli/src/lib/formatters/sixel-dashboard.ts @@ -9,7 +9,7 @@ */ import type { WidgetDataResult } from "../../types/dashboard.js"; -import { encodeImageToSixel } from "../sixel-image.js"; +import { type DecodedImage, encodeImageToSixel } from "../sixel-image.js"; import { buildCategoricalChartModel, buildChartModel, @@ -90,6 +90,11 @@ export type RenderSixelDashboardOptions = { innerWidth: number, contentHeight: number ) => string[]; + /** + * Encode the finished RGBA canvas to a terminal graphics escape string. + * Defaults to sixel; callers pass a kitty encoder on kitty-capable terminals. + */ + encodeImage?: (image: DecodedImage) => string | undefined; }; /** A widget paired with the layout used for the final composite. */ @@ -123,7 +128,9 @@ export function renderDashboardAsSixel( } // The canvas is already bounded by MAX_CANVAS_PIXELS and must match the // terminal width exactly to preserve the dashboard grid. - return encodeImageToSixel(image, image.width, true); + const encode = + options.encodeImage ?? ((img) => encodeImageToSixel(img, img.width, true)); + return encode(image); } /** Place layout-less widgets beneath the explicit dashboard grid. */ diff --git a/packages/cli/src/lib/kitty-image.ts b/packages/cli/src/lib/kitty-image.ts new file mode 100644 index 000000000..709c6c30e --- /dev/null +++ b/packages/cli/src/lib/kitty-image.ts @@ -0,0 +1,109 @@ +/** + * Runtime image → kitty graphics encoding. + * + * Turns a decoded raster image (PNG or JPEG) into a kitty graphics protocol + * escape sequence for inline display on kitty-capable terminals. This is the + * kitty companion to {@link ./sixel-image.ts}: newer terminals (kitty, WezTerm, + * Ghostty, recent Konsole) prefer the kitty protocol, which transmits full RGBA + * pixels directly — no palette quantization and native per-pixel alpha. + * + * Used by `sentry api` to render image attachments (screenshots, etc.) inline + * instead of dumping raw bytes into an interactive terminal, taking precedence + * over sixel when the terminal advertises kitty support. + * + * Protocol notes (see https://sw.kovidgoyal.net/kitty/graphics-protocol/): + * - Data is transmitted with the APC introducer `ESC _ G ; ` + * terminated by the String Terminator `ESC \`. + * - `a=T` transmits and immediately displays; `f=32` declares 32-bit RGBA; + * `s`/`v` give the pixel width/height of the raw buffer. + * - The base64 payload is split into <= 4096-byte chunks; every chunk but the + * last sets `m=1` (more follows), the final one sets `m=0`. + */ + +import { + DEFAULT_MAX_HEIGHT, + DEFAULT_MAX_WIDTH, + type DecodedImage, + decodeImage, + detectImageFormat, + downscale, +} from "./sixel-image.js"; + +/** Max base64 payload bytes per kitty transmission chunk (protocol limit). */ +const CHUNK_SIZE = 4096; + +/** + * Encode a decoded image as a kitty graphics escape sequence. + * + * The full RGBA buffer is transmitted directly, so transparency and color are + * preserved exactly (unlike the sixel path, which quantizes to a palette). + * Returns `undefined` when the image has no pixels. + * + * @param img - Decoded RGBA image. + * @param maxWidth - Cap on rendered pixel width; wider images are downscaled. + * Omit to use the default ceiling. + * @param preserveDimensions - Preserve explicitly supplied dimensions above the + * default ceilings. Callers must bound image dimensions first. + */ +export function encodeImageToKitty( + img: DecodedImage, + maxWidth?: number, + preserveDimensions = false +): string | undefined { + const effectiveMaxWidth = preserveDimensions + ? (maxWidth ?? DEFAULT_MAX_WIDTH) + : Math.min(maxWidth ?? DEFAULT_MAX_WIDTH, DEFAULT_MAX_WIDTH); + const scaled = downscale( + img, + effectiveMaxWidth, + preserveDimensions ? img.height : DEFAULT_MAX_HEIGHT + ); + const { width, height } = scaled; + if (width <= 0 || height <= 0) { + return; + } + + const payload = Buffer.from( + scaled.data.buffer, + scaled.data.byteOffset, + width * height * 4 + ).toString("base64"); + + // a=T transmit+display, f=32 RGBA, s/v pixel dimensions of the raw buffer. + const header = `a=T,f=32,s=${width},v=${height}`; + let out = ""; + for (let offset = 0; offset < payload.length; offset += CHUNK_SIZE) { + const chunk = payload.slice(offset, offset + CHUNK_SIZE); + const more = offset + CHUNK_SIZE < payload.length ? 1 : 0; + // The metadata keys ride the first chunk; later chunks only carry `m`. + const keys = offset === 0 ? `${header},m=${more}` : `m=${more}`; + out += `\x1b_G${keys};${chunk}\x1b\\`; + } + return out; +} + +/** + * Convenience: decode image bytes and encode them as a kitty graphics string in + * one step. Returns `undefined` when the format is unsupported, the bytes fail + * to decode, or the image has no pixels. + * + * @param body - Raw image bytes. + * @param contentType - Optional HTTP Content-Type, used as a decode-format hint. + * @param maxWidth - Cap on rendered pixel width (clamped to + * {@link DEFAULT_MAX_WIDTH}). Omit to use the default ceiling. + */ +export function imageBytesToKitty( + body: Uint8Array, + contentType?: string | null, + maxWidth?: number +): string | undefined { + const format = detectImageFormat(body, contentType); + if (!format) { + return; + } + const decoded = decodeImage(body, format); + if (!decoded) { + return; + } + return encodeImageToKitty(decoded, maxWidth); +} diff --git a/packages/cli/src/lib/sixel-image.ts b/packages/cli/src/lib/sixel-image.ts index ebfa46b37..1d8bbd42f 100644 --- a/packages/cli/src/lib/sixel-image.ts +++ b/packages/cli/src/lib/sixel-image.ts @@ -50,14 +50,14 @@ const ALPHA_THRESHOLD = 128; * downscaled so the sixel fits a typical terminal and stays small. Height is * scaled proportionally. */ -const DEFAULT_MAX_WIDTH = 800; +export const DEFAULT_MAX_WIDTH = 800; /** * Default cap on the rendered pixel height. Long screenshots (narrow but very * tall) would otherwise skip width-based downscaling entirely and produce a * huge escape sequence with heavy CPU/memory cost, so height is bounded too. */ -const DEFAULT_MAX_HEIGHT = 2000; +export const DEFAULT_MAX_HEIGHT = 2000; /** * Hard ceiling on either declared image dimension, checked from the header diff --git a/packages/cli/src/lib/sixel.ts b/packages/cli/src/lib/sixel.ts index 811255a42..da7480f11 100644 --- a/packages/cli/src/lib/sixel.ts +++ b/packages/cli/src/lib/sixel.ts @@ -9,6 +9,8 @@ * - Primary Device Attributes (`ESC [ c`) — attribute `4` means sixel. * - Text-area cell size (`ESC [ 16 t` → `ESC [ 6 ; H ; W t`) — used to check * the fixed-pixel image actually fits the current column width. + * - Kitty graphics query (`ESC _ G ... a=q ... ESC \`) — an `OK` reply means + * the terminal speaks the newer kitty protocol, preferred over sixel. * The probe is gated behind an interactive TTY, honors plain-output/opt-out * signals, has a short timeout, restores terminal state, and never throws. * The result is cached for the process. @@ -17,20 +19,26 @@ import { execSync } from "node:child_process"; import { closeSync, openSync, readSync, writeSync } from "node:fs"; import { BANNER_SIXEL } from "../generated/banner-sixel.js"; +import { getGraphicsPreference } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { isPlainOutput, isTruthyEnv } from "./formatters/plain-detect.js"; -/** Terminal sixel capabilities discovered by the probe. */ +/** Terminal graphics capabilities discovered by the probe. */ export type SixelCaps = { /** True when the terminal advertised sixel support (DA1 attribute 4). */ supported: boolean; + /** + * True when the terminal advertised kitty graphics support (it answered the + * graphics query with `OK`). Preferred over sixel when present. + */ + kitty?: boolean; /** Character-cell width in pixels (from `CSI 16 t`), when reported. */ cellWidth?: number; /** Character-cell height in pixels (from `CSI 16 t`), when reported. */ cellHeight?: number; }; -/** Shared "no sixel" result. */ +/** Shared "no graphics" result. */ const UNSUPPORTED: SixelCaps = { supported: false }; /** Primary DA reply: `ESC [ ? c` — attribute list; `4` == sixel. */ @@ -41,6 +49,17 @@ const DA1_RE = /\x1b\[\?([0-9;]*)c/; // biome-ignore lint/suspicious/noControlCharactersInRegex: parsing terminal escapes const CELL_SIZE_RE = /\x1b\[6;(\d+);(\d+)t/; +/** Kitty graphics query reply: `ESC _ G i=;OK ESC \`. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: parsing terminal escapes +const KITTY_RE = /\x1b_G[^\x1b]*;OK/; + +/** + * Kitty graphics query. Uploads a 1×1 RGB pixel (`f=24`) directly (`t=d`) with + * `a=q` so the terminal only answers with support status and draws nothing. A + * kitty-capable terminal replies `ESC _ G i=31;OK ESC \`; others ignore it. + */ +const KITTY_QUERY = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"; + let cached: SixelCaps | undefined; /** Clear the cached probe result. Test-only. */ @@ -49,19 +68,24 @@ export function __resetSixelCache(): void { } /** - * Parse a terminal's reply to the DA1 + cell-size queries. + * Parse a terminal's reply to the DA1 + cell-size + kitty queries. * * Pure and side-effect free so it can be unit-tested without a terminal. - * Returns {@link UNSUPPORTED} unless the DA1 attribute list contains `4`. + * Returns {@link UNSUPPORTED} unless the DA1 attribute list contains `4` + * (sixel) or the terminal answered the kitty graphics query with `OK`. */ export function parseSixelCaps(reply: string): SixelCaps { const da = reply.match(DA1_RE); const attrs = da?.[1]?.split(";") ?? []; - if (!attrs.includes("4")) { + const kitty = KITTY_RE.test(reply); + if (!(attrs.includes("4") || kitty)) { return UNSUPPORTED; } + const caps: SixelCaps = { supported: attrs.includes("4") }; + if (kitty) { + caps.kitty = true; + } const size = reply.match(CELL_SIZE_RE); - const caps: SixelCaps = { supported: true }; if (size) { caps.cellHeight = Number(size[1]); caps.cellWidth = Number(size[2]); @@ -91,7 +115,7 @@ export function sixelFits( */ export function optedOut(): boolean { // Use the isolation-aware env (matches isPlainOutput) so library/test runs - // that call setEnv() see consistent TERM / SENTRY_NO_SIXEL values. + // that call setEnv() see consistent TERM / SENTRY_NO_GRAPHICS values. const env = getEnv(); return ( !(process.stdout.isTTY && process.stdin.isTTY) || @@ -99,6 +123,7 @@ export function optedOut(): boolean { isPlainOutput() || !env.TERM || env.TERM === "dumb" || + isTruthyEnv(env.SENTRY_NO_GRAPHICS ?? "") || isTruthyEnv(env.SENTRY_NO_SIXEL ?? "") ); } @@ -159,8 +184,10 @@ function probe(): SixelCaps { savedStty = execSync("stty -g < /dev/tty", { encoding: "utf8" }).trim(); // min 0 time 3 => each read blocks up to ~300ms for (more) reply bytes. execSync("stty -echo -icanon min 0 time 3 < /dev/tty"); - // Cell-size query first, Primary DA last: DA's `c` is the drain sentinel. - writeSync(fd, "\x1b[16t\x1b[c"); + // Cell-size and kitty queries first, Primary DA last: DA's `c` is the + // drain sentinel every terminal answers, so the optional cell-size and + // kitty replies (which capable terminals send ahead of it) are all drained. + writeSync(fd, `\x1b[16t${KITTY_QUERY}\x1b[c`); return parseSixelCaps(readReply(fd)); } catch { return UNSUPPORTED; @@ -192,7 +219,8 @@ export function detectSixelCaps(): SixelCaps { /** * True when the current terminal can display sixel graphics right now: it's an - * interactive TTY, not opted out (plain-output / SENTRY_NO_SIXEL / non-unix), + * interactive TTY, not opted out (plain-output / SENTRY_NO_GRAPHICS / + * SENTRY_NO_SIXEL / non-unix), the persistent graphics=off default is not set, * and it advertised sixel support in the DA1 probe. * * Used by callers that render arbitrary images (not just the baked banner), @@ -202,9 +230,30 @@ export function canRenderSixel(): boolean { if (optedOut()) { return false; } + if (getGraphicsPreference() === false) { + return false; + } return detectSixelCaps().supported; } +/** + * True when the current terminal can display kitty graphics right now: it's an + * interactive TTY, not opted out (plain-output / SENTRY_NO_GRAPHICS / + * SENTRY_NO_SIXEL / non-unix), the persistent graphics=off default is not set, + * and it answered the kitty graphics query with `OK`. Newer terminals prefer + * this protocol, so callers rendering arbitrary images (e.g. `sentry api` + * attachments) check this before falling back to {@link canRenderSixel}. + */ +export function canRenderKitty(): boolean { + if (optedOut()) { + return false; + } + if (getGraphicsPreference() === false) { + return false; + } + return detectSixelCaps().kitty === true; +} + /** * The usable image width in device pixels for the current terminal — the * number of columns times the reported character-cell width. Returns @@ -219,7 +268,9 @@ export function terminalPixelWidth( columns: number = process.stdout.columns ?? 80 ): number | undefined { const caps = detectSixelCaps(); - if (!(caps.supported && caps.cellWidth && caps.cellWidth > 0)) { + if ( + !((caps.supported || caps.kitty) && caps.cellWidth && caps.cellWidth > 0) + ) { return; } return columns * caps.cellWidth; @@ -236,7 +287,9 @@ export function terminalPixelHeight( rows: number = process.stdout.rows ?? 24 ): number | undefined { const caps = detectSixelCaps(); - if (!(caps.supported && caps.cellHeight && caps.cellHeight > 0)) { + if ( + !((caps.supported || caps.kitty) && caps.cellHeight && caps.cellHeight > 0) + ) { return; } return rows * caps.cellHeight; @@ -257,6 +310,9 @@ export function sixelBanner( if (optedOut()) { return; } + if (getGraphicsPreference() === false) { + return; + } const caps = detectSixelCaps(); return sixelFits(caps, columns, BANNER_SIXEL.width) ? BANNER_SIXEL.data diff --git a/packages/cli/test/commands/cli/defaults.test.ts b/packages/cli/test/commands/cli/defaults.test.ts index a31d9b19d..f6f973555 100644 --- a/packages/cli/test/commands/cli/defaults.test.ts +++ b/packages/cli/test/commands/cli/defaults.test.ts @@ -140,6 +140,7 @@ describe("defaults storage", () => { url: "https://sentry.example.com", headers: null, "ca-cert": null, + graphics: null, }); }); @@ -153,6 +154,7 @@ describe("defaults storage", () => { url: null, headers: null, "ca-cert": null, + graphics: null, }); }); @@ -414,6 +416,7 @@ describe("formatDefaultsResult", () => { organization: "my-org", project: "my-proj", telemetry: "off", + graphics: "off", url: "https://sentry.example.com", }, telemetryEffective: { enabled: false, source: "preference" }, @@ -422,6 +425,7 @@ describe("formatDefaultsResult", () => { expect(plain).toContain("my-org"); expect(plain).toContain("my-proj"); expect(plain).toContain("off"); + expect(plain).toContain("Graphics"); expect(plain).toContain("sentry.example.com"); }); 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 ee511e84b..90144c438 100644 --- a/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts +++ b/packages/cli/test/lib/formatters/dashboard-sixel-integration.test.ts @@ -65,29 +65,22 @@ function makeDashboardData( } describe("dashboard sixel integration", () => { - let savedSixelEnv: string | undefined; let savedPlainOutput: string | undefined; let savedColumns: number | undefined; beforeEach(() => { - savedSixelEnv = process.env.SENTRY_DASHBOARD_SIXEL; savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; savedColumns = process.stdout.columns; - process.env.SENTRY_DASHBOARD_SIXEL = "1"; process.env.SENTRY_PLAIN_OUTPUT = "0"; process.stdout.columns = 40; vi.spyOn(sixelModule, "canRenderSixel").mockReturnValue(true); + vi.spyOn(sixelModule, "canRenderKitty").mockReturnValue(false); vi.spyOn(sixelModule, "terminalPixelWidth").mockReturnValue(320); vi.spyOn(sixelModule, "terminalPixelHeight").mockReturnValue(12); }); afterEach(() => { vi.restoreAllMocks(); - if (savedSixelEnv === undefined) { - delete process.env.SENTRY_DASHBOARD_SIXEL; - } else { - process.env.SENTRY_DASHBOARD_SIXEL = savedSixelEnv; - } if (savedPlainOutput === undefined) { delete process.env.SENTRY_PLAIN_OUTPUT; } else { @@ -114,23 +107,22 @@ describe("dashboard sixel integration", () => { expect(output).toContain('"1;1;320;144'); }); - test("uses displayType=timeseries_sixel as an opt-in signal", () => { + test("prefers kitty encoding when the terminal supports it", () => { + vi.spyOn(sixelModule, "canRenderKitty").mockReturnValue(true); const data = makeDashboardData({ widgets: [ makeWidget({ - title: "Explicit Sixel", - displayType: "timeseries_sixel", + title: "Kitty Chart", + displayType: "line", 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.split(`${ESC}P`)).toHaveLength(2); + // Kitty graphics use the APC introducer ESC _ G, not the sixel DCS ESC P. + expect(output).toContain(`${ESC}_G`); + expect(output).not.toContain(`${ESC}P`); }); test("renders scalar and timeseries widgets in the same sixel canvas", () => { diff --git a/packages/cli/test/lib/kitty-image.test.ts b/packages/cli/test/lib/kitty-image.test.ts new file mode 100644 index 000000000..76888126b --- /dev/null +++ b/packages/cli/test/lib/kitty-image.test.ts @@ -0,0 +1,114 @@ +/** + * Runtime image → kitty graphics encoder tests. + * + * Exercises the shape of the emitted kitty escape sequence (APC introducer, + * RGBA format keys, base64 payload, chunking) and the decode-then-encode + * convenience wrapper. Real terminal I/O is not involved — everything here is + * pure and deterministic. + */ + +import { PNG } from "pngjs"; +import { describe, expect, test } from "vitest"; +import { + encodeImageToKitty, + imageBytesToKitty, +} from "../../src/lib/kitty-image.js"; +import type { DecodedImage } from "../../src/lib/sixel-image.js"; + +const ESC = "\x1b"; + +/** Build a solid-color RGBA image of the given size. */ +function solidImage( + width: number, + height: number, + rgba: [number, number, number, number] +): DecodedImage { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + data[i * 4] = rgba[0]; + data[i * 4 + 1] = rgba[1]; + data[i * 4 + 2] = rgba[2]; + data[i * 4 + 3] = rgba[3]; + } + return { width, height, data }; +} + +/** Encode an RGBA DecodedImage as PNG bytes. */ +function toPngBytes(img: DecodedImage): Uint8Array { + const png = new PNG({ width: img.width, height: img.height }); + png.data = Buffer.from(img.data); + return new Uint8Array(PNG.sync.write(png)); +} + +describe("encodeImageToKitty", () => { + test("emits an APC graphics sequence with RGBA format and dimensions", () => { + const out = encodeImageToKitty(solidImage(2, 2, [255, 0, 0, 255])); + expect(out).toBeDefined(); + const s = out as string; + // APC introducer ... String Terminator. + expect(s.startsWith(`${ESC}_G`)).toBe(true); + expect(s.endsWith(`${ESC}\\`)).toBe(true); + // Transmit+display, 32-bit RGBA, and the pixel dimensions of the buffer. + expect(s).toContain("a=T"); + expect(s).toContain("f=32"); + expect(s).toContain("s=2"); + expect(s).toContain("v=2"); + }); + + test("encodes the pixel buffer as base64", () => { + const out = encodeImageToKitty(solidImage(1, 1, [1, 2, 3, 4])) as string; + const payload = out.slice(out.indexOf(";") + 1, out.indexOf(`${ESC}\\`)); + expect(Buffer.from(payload, "base64")).toEqual(Buffer.from([1, 2, 3, 4])); + }); + + test("chunks large payloads with m=1 on all but the last chunk", () => { + // 64×64 RGBA ≈ 16 KiB → ~21 KiB base64, well over the 4096-byte chunk cap. + const out = encodeImageToKitty( + solidImage(64, 64, [10, 20, 30, 255]) + ) as string; + // Each chunk is an APC sequence terminated by ESC \; split on the introducer. + const chunks = out + .split(`${ESC}_G`) + .filter((c) => c.length > 0) + .map((c) => `${ESC}_G${c}`); + expect(chunks.length).toBeGreaterThan(1); + // Every chunk but the last declares more data follows. + for (const chunk of chunks.slice(0, -1)) { + expect(chunk).toContain("m=1"); + } + expect(chunks.at(-1)).toContain("m=0"); + // Metadata keys ride only the first chunk. + expect(chunks[0]).toContain("f=32"); + expect(chunks[1]).not.toContain("f=32"); + }); + + test("downscales wide images to the max width", () => { + const out = encodeImageToKitty( + solidImage(2000, 10, [0, 0, 0, 255]) + ) as string; + expect(out).toContain("s=800"); + }); +}); + +describe("imageBytesToKitty", () => { + test("decodes PNG bytes and encodes them to kitty graphics", () => { + const png = toPngBytes(solidImage(3, 3, [0, 128, 255, 255])); + const out = imageBytesToKitty(png, "image/png"); + expect(out).toBeDefined(); + expect((out as string).startsWith(`${ESC}_G`)).toBe(true); + }); + + test("returns undefined for an unsupported format", () => { + expect( + imageBytesToKitty(new Uint8Array([1, 2, 3]), "text/plain") + ).toBeUndefined(); + }); + + test("returns undefined for bytes that fail to decode", () => { + // PNG magic bytes but a truncated/garbage body. + const bogus = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + expect(imageBytesToKitty(bogus, "image/png")).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/sixel.test.ts b/packages/cli/test/lib/sixel.test.ts index a013d1007..6d34b86da 100644 --- a/packages/cli/test/lib/sixel.test.ts +++ b/packages/cli/test/lib/sixel.test.ts @@ -80,6 +80,26 @@ describe("parseSixelCaps", () => { { numRuns: DEFAULT_NUM_RUNS } ); }); + + test("detects kitty support from an OK graphics reply", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62c`); + expect(caps.kitty).toBe(true); + }); + + test("kitty support does not imply sixel support", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62c`); + expect(caps.supported).toBe(false); + }); + + test("reports both when the terminal advertises sixel and kitty", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;OK${ESC}\\${ESC}[?62;4c`); + expect(caps).toMatchObject({ supported: true, kitty: true }); + }); + + test("a kitty error reply is not treated as support", () => { + const caps = parseSixelCaps(`${ESC}_Gi=31;ENOENT:bad${ESC}\\${ESC}[?62c`); + expect(caps.kitty).toBeUndefined(); + }); }); describe("sixelFits", () => { @@ -171,6 +191,7 @@ describe("optedOut", () => { stdin: process.stdin.isTTY, TERM: process.env.TERM, SENTRY_NO_SIXEL: process.env.SENTRY_NO_SIXEL, + SENTRY_NO_GRAPHICS: process.env.SENTRY_NO_GRAPHICS, NO_COLOR: process.env.NO_COLOR, SENTRY_PLAIN_OUTPUT: process.env.SENTRY_PLAIN_OUTPUT, FORCE_COLOR: process.env.FORCE_COLOR, @@ -189,6 +210,7 @@ describe("optedOut", () => { process.stdin.isTTY = saved.stdin; setEnv("TERM", saved.TERM); setEnv("SENTRY_NO_SIXEL", saved.SENTRY_NO_SIXEL); + setEnv("SENTRY_NO_GRAPHICS", saved.SENTRY_NO_GRAPHICS); setEnv("NO_COLOR", saved.NO_COLOR); setEnv("SENTRY_PLAIN_OUTPUT", saved.SENTRY_PLAIN_OUTPUT); setEnv("FORCE_COLOR", saved.FORCE_COLOR); @@ -251,6 +273,11 @@ describe("optedOut", () => { expect(optedOut()).toBe(false); } }); + + test("SENTRY_NO_GRAPHICS opts out", () => { + setEnv("SENTRY_NO_GRAPHICS", "1"); + expect(optedOut()).toBe(true); + }); }); describe("readReply", () => {