diff --git a/script/build.ts b/script/build.ts index d1ad7532e..42fdee0b6 100644 --- a/script/build.ts +++ b/script/build.ts @@ -508,11 +508,11 @@ async function build(): Promise { await uploadSourcemapToSentry(); // Clean up intermediate bundle (only the binaries are artifacts). - // The `opentui-app.tsx` copy comes from the text-import-plugin's - // `with { type: "file" }` handling — it gets embedded into the - // compiled binary, so the sidecar copy is no longer needed once + // The `*-app.tsx` copies come from the text-import-plugin's + // `with { type: "file" }` handling — they get embedded into the + // compiled binary, so the sidecar copies aren't needed once // every target has compiled. - await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/opentui-app.tsx`; + await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/opentui-app.tsx dist-bin/dashboard-app.tsx`; // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/bundle.ts b/script/bundle.ts index c88eaba69..559eebc10 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -293,17 +293,21 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS); console.log(" -> dist/bin.cjs (CLI wrapper)"); console.log(" -> dist/index.d.cts (type declarations)"); -// Clean up the `opentui-app.tsx` sidecar that the text-import-plugin -// drops into `dist/` when it sees the `with { type: "file" }` import -// in `src/lib/init/ui/opentui-ui.ts`. The npm distribution doesn't -// run the OpenTuiUI factory at all (it's gated to the Bun binary), -// so the sidecar is unused — and it's not in `package.json#files` -// either, so it wouldn't ship even without this cleanup. Removing -// it just keeps the local `dist/` directory tidy. -try { - await unlink("./dist/opentui-app.tsx"); -} catch { - // Sidecar may not exist (e.g. plugin path not exercised) — fine. +// Clean up the `*-app.tsx` sidecars that the text-import-plugin +// drops into `dist/` when it sees the `with { type: "file" }` +// imports in `src/lib/init/ui/opentui-ui.ts` (wizard) and +// `src/lib/formatters/dashboard-tui.ts` (dashboard view). The npm +// distribution gates both factories to the Bun binary, so the +// sidecars are unused at runtime — and they're not in +// `package.json#files` either, so they wouldn't ship even without +// this cleanup. Removing them just keeps the local `dist/` +// directory tidy. +for (const sidecar of ["./dist/opentui-app.tsx", "./dist/dashboard-app.tsx"]) { + try { + await unlink(sidecar); + } catch { + // Sidecar may not exist (e.g. plugin path not exercised) — fine. + } } // Calculate bundle size (only the main bundle, not source maps) diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts index 41a9383c3..7fd427fe5 100644 --- a/src/commands/dashboard/view.ts +++ b/src/commands/dashboard/view.ts @@ -41,6 +41,39 @@ import { resolveOrgFromTarget, } from "./resolve.js"; +/** + * True when the dashboard should mount the interactive Bun-binary + * TUI rather than the static one-shot renderer. Requires a + * real TTY on both stdin (for keystrokes) and stdout (for the + * alternate-screen takeover) AND non-JSON output mode AND the + * Bun runtime (where the OpenTUI bindings can load). + * + * Stays a function rather than a boolean so the test suite can + * mock TTY-ness per-test without import-order timing issues. + * + * The `stdout` argument is the Writer from `SentryContext`. We + * read `.isTTY` via a structural type because the `Writer` shape + * deliberately omits TTY metadata to keep library-mode consumers + * pluggable — but in practice the production stdout is + * `process.stdout` and exposes the flag. + */ +function isInteractiveContext( + flags: ViewFlags, + stdin: NodeJS.ReadStream, + stdout: { isTTY?: boolean } +): boolean { + if (flags.json) { + return false; + } + if (!(stdin.isTTY && stdout.isTTY)) { + return false; + } + // The Bun-compiled binary exposes `process.versions.bun`. The + // npm/Node distribution doesn't. The interactive runtime + // imports OpenTUI which only loads under Bun. + return typeof process.versions.bun === "string"; +} + /** Default auto-refresh interval in seconds */ const DEFAULT_REFRESH_INTERVAL = 60; @@ -97,6 +130,11 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { /** * Build the DashboardViewData from a dashboard and its widget query results. + * + * The returned object is also passed through {@link tryPreRenderTui} + * before being yielded so the OpenTUI string lives on the data + * itself — keeps the human renderer synchronous while letting us + * await the async OpenTUI rendering in the command body. */ function buildViewData( dashboard: { @@ -134,6 +172,53 @@ function buildViewData( }; } +/** + * Try to pre-render the dashboard with OpenTUI. Returns the + * passed-in data with `rendered` populated on success; returns + * the original data unchanged on failure (e.g. on the npm/Node + * distribution where OpenTUI's native bindings can't load). + * + * Skipped entirely in JSON mode — JSON output uses the raw data + * shape, so there's no point spending the render cycles. + * + * **Lazy import.** `dashboard-tui.js` is loaded via dynamic + * `await import()` (rather than a top-level `import` statement) + * so its module-level `with { type: "file" }` resolution and + * heavy OpenTUI dependencies never load when this command isn't + * the one being run. Tests that walk the Stricli route map (via + * `app.ts`) would otherwise eagerly evaluate the OpenTUI side + * effects and fail with module-cache-collision errors the same + * way the wizard's `OpenTuiUI` did before its `?bridge=1` fix. + */ +async function tryPreRenderTui( + data: DashboardViewData, + flags: ViewFlags +): Promise { + if (flags.json) { + return data; + } + try { + const { renderDashboardTui } = await import( + "../../lib/formatters/dashboard-tui.js" + ); + const rendered = await renderDashboardTui(data); + return { ...data, rendered }; + } catch (err) { + // Fall back to the plain-text formatter. The human renderer + // checks `data.rendered === undefined` and uses + // `formatDashboardWithData` in that case. We log at debug + // level so a missing-binding diagnosis is recoverable; we + // don't surface to the user because the fallback is fully + // functional. + logger.debug( + `OpenTUI dashboard render unavailable, using plain-text fallback: ${ + err instanceof Error ? err.message : String(err) + }` + ); + return data; + } +} + /** * Resolve the effective time range for a dashboard view. * @@ -150,6 +235,103 @@ function resolveViewTimeRange( return dashboardPeriod ? parsePeriod(dashboardPeriod) : TIME_RANGE_24H; } +/** + * Inputs for the interactive dashboard runtime. Bundled into a + * single object so the helper can stay readable instead of + * threading 9+ positional args. + */ +type InteractiveContext = { + regionUrl: string; + orgSlug: string; + url: string; + dashboard: Awaited>; + widgets: DashboardWidget[]; + widgetTimeOpts: + | { period: string } + | { start: string | undefined; end: string | undefined }; + /** + * Seconds covered by the current period. `undefined` is the + * legitimate "couldn't compute" return from `timeRangeToSeconds` + * for malformed absolute ranges; downstream API code accepts + * undefined and falls back to its own period parsing. + */ + periodSeconds: number | undefined; + timeRange: TimeRange; + /** From `flags.refresh` — undefined when auto-refresh is off. */ + refreshSeconds: number | undefined; +}; + +/** + * Fetch initial widget data and hand off to the OpenTUI runtime. + * + * Returns `true` when the runtime took over and ran to user-quit; + * the caller should `return` from `func()` immediately. Returns + * `false` when the runtime is unavailable (npm/Node distribution, + * unusual environment) so the caller can fall through to the + * non-interactive path. + * + * Lazy-imports `dashboard-runtime.js` for the same reason + * `tryPreRenderTui` lazy-imports `dashboard-tui.js`: keep + * OpenTUI references out of the npm bundle's static module + * graph. + */ +async function tryRunInteractive(ctx: InteractiveContext): Promise { + // Initial fetch happens before mounting the renderer so any + // error (auth, 404, network) surfaces in the normal stderr + // stream rather than getting wiped by the alternate-screen + // takeover. + const initialWidgetData = await withProgress( + { message: "Querying widget data...", json: false }, + () => + queryAllWidgets(ctx.regionUrl, ctx.orgSlug, ctx.dashboard, { + ...ctx.widgetTimeOpts, + periodSeconds: ctx.periodSeconds, + }) + ); + const initialData = buildViewData( + ctx.dashboard, + initialWidgetData, + ctx.widgets, + { period: formatTimeRangeFlag(ctx.timeRange), url: ctx.url } + ); + + try { + const { runInteractiveDashboard } = await import( + "../../lib/formatters/dashboard-runtime.js" + ); + await runInteractiveDashboard({ + initialData, + initialPeriod: formatTimeRangeFlag(ctx.timeRange), + orgSlug: ctx.orgSlug, + fetch: async ({ period }) => { + const fresh = await queryAllWidgets( + ctx.regionUrl, + ctx.orgSlug, + ctx.dashboard, + { period, periodSeconds: timeRangeToSeconds(parsePeriod(period)) } + ); + return buildViewData(ctx.dashboard, fresh, ctx.widgets, { + period, + url: ctx.url, + }); + }, + autoRefreshIntervalMs: + ctx.refreshSeconds !== undefined + ? ctx.refreshSeconds * 1000 + : undefined, + initialAutoRefresh: ctx.refreshSeconds !== undefined, + }); + return true; + } catch (err) { + logger.debug( + `Interactive dashboard unavailable, falling back to static render: ${ + err instanceof Error ? err.message : String(err) + }` + ); + return false; + } +} + export const viewCommand = buildCommand({ docs: { brief: "View a dashboard", @@ -172,6 +354,11 @@ export const viewCommand = buildCommand({ }, output: { human: createDashboardViewRenderer, + // `rendered` is the pre-baked OpenTUI ANSI string that the + // human renderer prints directly. Strip it from JSON output — + // machine consumers want the structured widget data, not a + // pre-formatted screen capture. + jsonExclude: ["rendered"], }, parameters: { positional: { @@ -205,6 +392,7 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web", r: "refresh", t: "period" }, }, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential dispatch across web/interactive/refresh-poll/single-fetch modes is inherently flat; further splitting would spread one logical flow across multiple helpers without simplifying the branching async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { applyFreshFlag(flags); const { cwd } = this; @@ -246,6 +434,40 @@ export const viewCommand = buildCommand({ : { start: timeRange.start, end: timeRange.end }; const widgets = dashboard.widgets ?? []; + // Interactive path — Bun binary, real TTY, non-JSON. Mounts a + // long-lived OpenTUI app that owns the alternate screen until + // the user quits. The `--refresh N` flag becomes "start with + // auto-refresh enabled at N-second interval"; without it, + // auto-refresh starts off and the user can toggle with `R`. + if ( + isInteractiveContext( + flags, + this.stdin, + // The Writer type doesn't expose `isTTY` (kept abstract + // for library-mode consumers), but the production stdout + // is `process.stdout` and does. Cast to read the flag + // without coupling Writer to Node's stream shape. + this.stdout as unknown as { isTTY?: boolean } + ) + ) { + const handled = await tryRunInteractive({ + regionUrl, + orgSlug, + url, + dashboard, + widgets, + widgetTimeOpts, + periodSeconds, + timeRange, + refreshSeconds: flags.refresh, + }); + if (handled) { + return; + } + // tryRunInteractive returned false → fall through to the + // non-interactive paths below. + } + if (flags.refresh !== undefined) { // ── Refresh mode: poll and re-render ── const interval = flags.refresh; @@ -277,11 +499,18 @@ export const viewCommand = buildCommand({ { ...widgetTimeOpts, periodSeconds } ); - // Build output data before clearing so clear→render is instantaneous - const viewData = buildViewData(dashboard, widgetData, widgets, { - period: formatTimeRangeFlag(timeRange), - url, - }); + // Build output data before clearing so clear→render is + // instantaneous. `tryPreRenderTui` runs the OpenTUI + // pipeline (Bun binary) or short-circuits to the + // plain-text fallback (Node) — either way we yield a + // `CommandOutput` carrying ready-to-print state. + const viewData = await tryPreRenderTui( + buildViewData(dashboard, widgetData, widgets, { + period: formatTimeRangeFlag(timeRange), + url, + }), + flags + ); if (!isFirstRender) { yield new ClearScreen(); @@ -309,10 +538,13 @@ export const viewCommand = buildCommand({ ); yield new CommandOutput( - buildViewData(dashboard, widgetData, widgets, { - period: formatTimeRangeFlag(timeRange), - url, - }) + await tryPreRenderTui( + buildViewData(dashboard, widgetData, widgets, { + period: formatTimeRangeFlag(timeRange), + url, + }), + flags + ) ); return { hint: `Dashboard: ${url}` }; }, diff --git a/src/lib/formatters/dashboard-app.tsx b/src/lib/formatters/dashboard-app.tsx new file mode 100644 index 000000000..91cd99602 --- /dev/null +++ b/src/lib/formatters/dashboard-app.tsx @@ -0,0 +1,848 @@ +/** + * Dashboard view — OpenTUI React App + * + * Renders a `DashboardViewData` snapshot as a React tree built from + * OpenTUI primitives. Used by `dashboard view` on the Bun-compiled + * binary; the npm/Node distribution can't load OpenTUI's Zig + * bindings so it falls back to the plain-text renderer in + * `dashboard.ts` (the same one that's been there pre-TUI). + * + * Two render modes: + * + * 1. **Static** — mounted via `createTestRenderer` from + * `dashboard-tui.ts` for one-shot capture. The keyboard + * hooks fire against an off-screen renderer that has no + * input source, so they're effectively no-ops; the App + * renders the initial store state and we capture it. + * + * 2. **Interactive** — mounted via `createCliRenderer` from + * `dashboard-runtime.ts` for a live, keyboard-driven session. + * The same App reads / mutates the store in response to + * user input. Tab cycles widget focus; Enter drills down + * on the focused widget; `?` toggles help; `q` / Ctrl+C + * dispatches a `quit` action; `t` / `r` / `R` / `o` + * dispatch period-change / refresh / auto-refresh-toggle / + * browser-open actions for the runtime to service. + * + * Layout strategy: + * + * The Sentry dashboard grid is 6 columns wide with widgets at + * `(x, y, w, h)` positions in grid units. We approximate it with + * nested flex boxes: + * + * - Outer column (`flexDirection="column"`) + * - One row group per distinct `y` + * - Inner row (`flexDirection="row"`) + * - Widgets sorted by `x`, each sized to + * `(w / 6) * terminal_width` + * + * This handles the common case where all widgets in a row share + * the same `y` and `h`. Widgets with a `y` that doesn't match + * any other widget render at their own height in their own row + * group — visually they don't overlap with the next row group + * the way the plain-text framebuffer's true 2D composition does. + * The trade-off: simpler code, slightly different layout for + * dashboards that depend on a tall widget spanning multiple row + * groups. Most dashboards we've seen are uniform-height per row, + * so the approximation lands clean. + * + * Per-widget content reuses `renderContentLines()` from + * `dashboard.ts` — the same helper the plain-text renderer uses, + * which already handles sparklines, big-number ASCII fonts, + * tables, and markdown text. OpenTUI's `` strips ANSI from + * its content string, so we `stripAnsi()` the helper output before + * dropping it into the React tree and apply colors via the `fg` + * prop at the per-row level. Colors get less granular (one per + * row instead of per-segment) but the layout — which is what + * OpenTUI is buying us — comes out right. + */ + +import { useKeyboard } from "@opentui/react"; +import { useSyncExternalStore } from "react"; +import { + type DashboardViewData, + type DashboardViewWidget, + renderContentLines, +} from "./dashboard.js"; +import type { DashboardStore } from "./dashboard-store.js"; +import { stripAnsi } from "./plain-detect.js"; + +// ────────────────────────── Visual constants ───────────────────────── + +/** Sentry brand purple — matches the wizard. */ +const ACCENT = "#A77DC3"; +/** Muted gray for borders + secondary text. */ +const MUTED = "#6E6C7E"; +/** Foreground/body text. */ +const FOREGROUND = "#E8E6F0"; +/** Cyan for series labels and badges. */ +const CYAN = "#7DD3FC"; +/** Green for big numbers, success states, and bar fills. */ +const GREEN = "#86EFAC"; +/** Yellow for environment badges + auto-refresh ON state. */ +const YELLOW = "#FBBF24"; +/** Red for errors. */ +const ERROR = "#F87171"; + +/** Sentry dashboard grid columns — must stay in sync with the API. */ +const GRID_COLS = 6; + +/** Terminal lines per grid height unit (matches the Sentry web grid). */ +const LINES_PER_UNIT = 6; + +/** Bold attribute bit for OpenTUI's `attributes` prop. */ +const BOLD = 1; + +// Keyboard handler — extracted to a top-level function so the +// `useKeyboard` callback in `App` stays under the cognitive- +// complexity ceiling biome enforces. Split across three phase +// helpers (`handleEscape`, `handleHelpOverlay`, `handleDrilldown`, +// `handleGridKey`) so each piece owns one overlay's input. +// +// Exported for unit testing — the live `useKeyboard` flow can't +// be exercised from `bun test` without mounting a real renderer, +// so `dashboard-app.handlers.test.ts` calls these directly with +// synthetic events. + +export type KeyEventLike = { + name: string; + ctrl?: boolean; + shift?: boolean; + sequence?: string; +}; + +export type KeyboardSnapshot = { + drilldownActive: boolean; + helpOverlayActive: boolean; +}; + +/** + * Top-level keyboard dispatch. Routes the event to the phase + * handler appropriate for the current overlay state, with two + * universal short-circuits: + * + * 1. **Ctrl+C** — quits unconditionally, regardless of overlay. + * 2. **Esc** — staged dismissal: close drilldown if active, else + * close help, else quit. Mirrors the `vim` / `less` "back + * out one layer at a time" convention. + */ +export function handleKey( + event: KeyEventLike, + snapshot: KeyboardSnapshot, + store: DashboardStore +): void { + if (event.ctrl && event.name === "c") { + store.dispatch({ kind: "quit" }); + return; + } + if (event.name === "escape") { + handleEscape(snapshot, store); + return; + } + if (snapshot.helpOverlayActive) { + handleHelpKey(event, store); + return; + } + if (snapshot.drilldownActive) { + handleDrilldownKey(event, store); + return; + } + handleGridKey(event, store); +} + +function handleEscape(snapshot: KeyboardSnapshot, store: DashboardStore): void { + if (snapshot.drilldownActive) { + store.exitDrilldown(); + return; + } + if (snapshot.helpOverlayActive) { + store.exitHelp(); + return; + } + store.dispatch({ kind: "quit" }); +} + +function handleHelpKey(event: KeyEventLike, store: DashboardStore): void { + if (event.name === "?" || event.sequence === "?") { + store.toggleHelp(); + return; + } + if (event.name === "q") { + store.dispatch({ kind: "quit" }); + } +} + +function handleDrilldownKey(event: KeyEventLike, store: DashboardStore): void { + if (event.name === "return" || event.name === "enter") { + store.exitDrilldown(); + return; + } + if (event.name === "q") { + store.dispatch({ kind: "quit" }); + } +} + +/** + * Grid-mode bindings. `?` toggles help, `q` quits, Tab/arrows + * cycle widget focus, Enter drills into the focused widget, and + * `t` / `r` / `R` / `o` dispatch period-cycle / refresh / + * auto-refresh-toggle / browser-open actions. Capital R is + * detected via either `shift: true` or the literal sequence "R" + * because terminal emulators differ in how they report it. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: keyboard dispatch is inherently a flat switch over many independent keys; splitting further would just spread one case across multiple files +function handleGridKey(event: KeyEventLike, store: DashboardStore): void { + const name = event.name; + if (name === "?" || event.sequence === "?") { + store.toggleHelp(); + return; + } + if (name === "q") { + store.dispatch({ kind: "quit" }); + return; + } + if ((name === "tab" && event.shift) || name === "backtab") { + store.cycleFocus("backward"); + return; + } + if (name === "tab" || name === "right" || name === "down") { + store.cycleFocus("forward"); + return; + } + if (name === "left" || name === "up") { + store.cycleFocus("backward"); + return; + } + if (name === "return" || name === "enter") { + store.toggleDrilldown(); + return; + } + if (name === "t") { + store.dispatch({ kind: "cycle-period" }); + return; + } + if ((name === "r" && event.shift) || event.sequence === "R") { + store.dispatch({ kind: "toggle-auto-refresh" }); + return; + } + if (name === "r") { + store.dispatch({ kind: "refresh" }); + return; + } + if (name === "o") { + store.dispatch({ kind: "open-in-browser" }); + } +} + +// ────────────────────────────── App entry ──────────────────────────── + +export type AppProps = { + store: DashboardStore; + /** Total terminal width to lay out within. */ + termWidth: number; +}; + +/** + * Root component. Subscribes once at the top, then drills snapshot + * fields into presentational children. Holds the global keyboard + * handler that maps user keystrokes to store mutations or action + * dispatches. + * + * The keyboard handler stays at the App level (rather than per + * widget) for two reasons: + * + * 1. OpenTUI's `useKeyboard` registers with the renderer's global + * key bus. Per-widget hooks would all fire for every keystroke + * regardless of focus — there's no built-in "focused element + * receives input" routing — so we'd need to gate each + * handler with `if (focusedIndex === thisIndex)` anyway. + * 2. The same handler needs to coordinate between focus + * navigation, drilldown toggle, help overlay, and action + * dispatch, all of which depend on the current overlay state + * (e.g. Esc closes drilldown if active, else closes help, else + * quits). Centralised handler keeps the priority order + * legible. + */ +export function App({ store, termWidth }: AppProps): React.ReactNode { + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot + ); + + useKeyboard((event) => { + handleKey(event, snapshot, store); + }); + + // Drilldown takes over the entire screen — header still renders + // for context but the grid is replaced by the focused widget's + // expanded content. + if (snapshot.drilldownActive && snapshot.focusedWidgetIndex >= 0) { + return ( + +
+ + + + ); + } + + return ( + +
+ + {snapshot.helpOverlayActive ? : null} + + + ); +} + +// ─────────────────────────────── Header ────────────────────────────── + +/** + * Compact dashboard header: bold title, cyan period badge, optional + * yellow environment badge, an auto-refresh / fetching indicator + * when relevant, then a muted underline rule the full width of the + * terminal. + * + * The period shown is `snapshot.currentPeriod` (which the runtime + * keeps in sync with the most-recent fetch) rather than + * `data.period` — important when the user cycles via `t` and the + * data hasn't yet returned. + */ +function Header({ + data, + snapshot, + termWidth, +}: { + data: DashboardViewData; + snapshot: { + currentPeriod: string; + fetching: boolean; + autoRefreshEnabled: boolean; + }; + termWidth: number; +}): React.ReactNode { + const hasEnv = Boolean(data.environment?.length); + const envText = hasEnv ? `env: ${data.environment?.join(", ") ?? ""}` : ""; + return ( + + + + {data.title} + + {" "} + {`[${snapshot.currentPeriod}]`} + {hasEnv ? ( + <> + {" "} + {envText} + + ) : null} + {snapshot.autoRefreshEnabled ? ( + <> + {" "} + ● live + + ) : null} + {snapshot.fetching ? ( + <> + {" "} + refreshing… + + ) : null} + + {"─".repeat(termWidth)} + + ); +} + +// ──────────────────────────── Widget grid ──────────────────────────── + +/** + * Group widgets by their grid `y` row, sort each row by `x`, and + * render the rows top-to-bottom. Widgets without a `layout` are + * appended at the end as full-width rows — covers older + * dashboards that pre-date Sentry's grid layout. + * + * Threads `focusedIndex` (an index into the original `widgets` + * array, before bucketing) down to each `Widget` so the focused + * one gets the accent border treatment. + */ +function WidgetGrid({ + widgets, + termWidth, + focusedIndex, +}: { + widgets: DashboardViewWidget[]; + termWidth: number; + focusedIndex: number; +}): React.ReactNode { + // Bucket widgets by their starting y position. Track the + // original index alongside each entry so we can pass it to the + // Widget component for focus comparison. + type Indexed = { widget: DashboardViewWidget; index: number }; + const rows = new Map(); + const orphans: Indexed[] = []; + + for (const [i, widget] of widgets.entries()) { + const indexed: Indexed = { widget, index: i }; + if (widget.layout) { + const key = widget.layout.y; + const bucket = rows.get(key); + if (bucket) { + bucket.push(indexed); + } else { + rows.set(key, [indexed]); + } + } else { + orphans.push(indexed); + } + } + + const sortedRowKeys = [...rows.keys()].sort((a, b) => a - b); + + return ( + + {sortedRowKeys.map((y) => { + const rowWidgets = (rows.get(y) ?? []).sort( + (a, b) => (a.widget.layout?.x ?? 0) - (b.widget.layout?.x ?? 0) + ); + return ( + + ); + })} + {orphans.map(({ widget, index }) => ( + + ))} + + ); +} + +/** + * One row group (all widgets sharing a `y` start position) laid + * out side-by-side with proportional widths. `marginBottom={1}` + * leaves a one-row gap before the next row group so adjacent + * widget borders don't fuse together. + */ +function WidgetRow({ + widgets, + termWidth, + focusedIndex, +}: { + widgets: { widget: DashboardViewWidget; index: number }[]; + termWidth: number; + focusedIndex: number; +}): React.ReactNode { + return ( + + {widgets.map(({ widget, index }) => { + const w = widget.layout?.w ?? GRID_COLS; + const widgetWidth = Math.floor((w / GRID_COLS) * termWidth); + return ( + + ); + })} + + ); +} + +// ────────────────────────────── Widget ─────────────────────────────── + +/** + * One widget rendered inside a rounded bordered box. The border's + * `title` prop carries the widget title (Ink-style). Content height + * is `layout.h * LINES_PER_UNIT` to mirror the Sentry web grid's + * vertical units. + * + * Focused widgets get the accent purple border + bold title so + * the user can tell at a glance which widget Tab will operate on + * next. Unfocused widgets stay muted gray — the contrast is the + * key affordance. + */ +function Widget({ + widget, + width, + focused, +}: { + widget: DashboardViewWidget; + width: number; + focused: boolean; +}): React.ReactNode { + const layoutH = widget.layout?.h ?? 1; + const totalHeight = layoutH * LINES_PER_UNIT; + // Border accounts for 2 rows; padding of 1 cell on each side + // matches the visual breathing room the plain-text border + // wrapper provides. + const innerWidth = Math.max(0, width - 4); + const contentHeight = Math.max(0, totalHeight - 2); + + // Get the per-widget content lines from the shared helper. ANSI + // is stripped because OpenTUI's `` doesn't honor embedded + // escape codes. Colors come back via the `fg` prop on the row's + // wrapper component. + const rawLines = renderContentLines({ + widget, + innerWidth, + contentHeight, + }); + const lines = rawLines.map(stripAnsi); + + const borderColor = focused ? ACCENT : MUTED; + const titleText = focused ? `▸ ${widget.title}` : widget.title; + + return ( + + + + ); +} + +/** + * Render the per-widget content lines with colors chosen based on + * the widget's data type: + * + * - `timeseries` / `table` / `text`: foreground for body, with + * the first row of tables drawn bold (the header) and the + * second row muted (the separator rule). + * - `scalar`: green throughout (matches the chalk-styled output + * of the plain-text renderer's big-number font). + * - `error`: every row red. + * - `unsupported`: every row muted. + * + * The plain-text renderer can color individual cells within a row + * (e.g. cyan label + magenta sparkline + bold value on the same + * line). OpenTUI's `` is one color per text node, so we lose + * that per-segment richness here. The trade-off is OpenTUI handles + * border drawing + grid layout automatically — the win is bigger + * than the loss. + */ +function WidgetContentRows({ + lines, + type, + widget, +}: { + lines: string[]; + type: DashboardViewWidget["data"]["type"]; + widget: DashboardViewWidget; +}): React.ReactNode { + const styling = rowStylingFor(type); + return ( + + {lines.map((line, i) => { + const { fg, attrs } = styling(i, widget); + return ( + // Content rows are positionally stable for a given widget + // data snapshot — `renderContentLines` is pure of a given + // input, so the index makes a fine key. + // biome-ignore lint/suspicious/noArrayIndexKey: positional rows + + {line} + + ); + })} + + ); +} + +/** + * Per-widget-type row styling function. Returns a callback that + * resolves the `fg` color and `attributes` for a given row index. + * Some types (e.g. `table`) have row-position-dependent styling + * (header bold, separator muted, body plain). + */ +function rowStylingFor(type: DashboardViewWidget["data"]["type"]): ( + idx: number, + widget: DashboardViewWidget +) => { + fg: string; + attrs: number; +} { + if (type === "table") { + return (idx) => { + if (idx === 0) { + return { fg: FOREGROUND, attrs: BOLD }; + } + if (idx === 1) { + return { fg: MUTED, attrs: 0 }; + } + return { fg: FOREGROUND, attrs: 0 }; + }; + } + if (type === "scalar") { + return () => ({ fg: GREEN, attrs: BOLD }); + } + if (type === "error") { + return () => ({ fg: ERROR, attrs: 0 }); + } + if (type === "unsupported") { + return () => ({ fg: MUTED, attrs: 0 }); + } + if (type === "timeseries") { + // First row of a vertical-bar timeseries is the header + // (`