← ui

terminal-editor-screen

lib

helix's screen as data, drawn live by terminal-editor and as an SVG still

npx shadcn add @ag/terminal-editor-screen
Already installed? Preview an update
npx shadcn add @ag/terminal-editor-screen --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-06
  • New: what terminal-editor shows at an instant, as data in columns and rows (editorScreen: the scroll, soft-wrap, each run's ink, undercurl and selection, the gutter's mark, the message at a line's end, the caret, the bufferline, the status and command lines), the screen at a progress of an edit, coloured (editorStill), and that screen as the terminal's window in SVG (renderEditorSvg, Geist Mono by default, any measured MonoFace). Framework-free, so a build script or an og card draws the editor without React; the live editor draws the same model. A popup or the diagnostics picker throws in a still: not drawn there yet.
  • Added: renderEditorSvg(screen, { statusLines: false }) leaves the status and command lines out, for a still seen too small to read them (a link preview's thumbnail), the text's own padding under its last row.
Updated 2026-11-06 · release notes

shelf devices & demos

part of terminal-editor

uses terminal-session, code-session, code

the demo above, verbatim

terminal-editor-screen.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  type CodeFile,
  type CodeStep,
  codeTimeline,
  diagnoseWords,
} from "@/registry/base-nova/lib/code-session"
import {
  editorStill,
  renderEditorSvg,
} from "@/registry/base-nova/lib/terminal-editor-screen"
import { GEIST_MONO } from "@/registry/base-nova/lib/terminal-session"
import { Terminal } from "@/registry/base-nova/ui/terminal"
import { TerminalEditor } from "@/registry/base-nova/ui/terminal-editor"

// #region problem
// A typo typed, and what the language server says of it.
const SOURCES: CodeFile[] = [
  {
    path: "src/main.ts",
    text: `import { greet, names } from "./names"

for (const name of names) console.log(greet(name))
`,
  },
]
const TS = diagnoseWords([
  {
    word: "lenght",
    message: "Property 'lenght' does not exist on type 'string[]'",
    source: "typescript",
  },
])
const TYPO: CodeStep[] = [
  { kind: "open", path: "src/main.ts" },
  { kind: "type", line: 3, text: "\nconsole.log(names.lenght)", delay: 300 },
]
// #endregion

// #region wrap
// Two files open, a line longer than the screen soft-wrapped, a line being typed.
const NOTES: CodeFile[] = [
  { path: "readme.md", text: "# trip\n" },
  {
    path: "notes.csv",
    text: `date,place,people,what happened,what it cost,who paid,how it was paid
2026-08-14,the coast,4,fuel and tolls on the way down,84.20,ana,card
`,
  },
]
const TYPING: CodeStep[] = [
  { kind: "open", path: "readme.md" },
  { kind: "open", path: "notes.csv", delay: 300 },
  {
    kind: "type",
    line: 2,
    text: "\n2026-08-15,the market,2,groceries for the week,96.75,luis,cash",
    delay: 300,
  },
]
// #endregion

/** The width both are drawn at: the still is given it, the live one measures it. */
const COLUMNS = 60
const SIZE = 13

/** The live editor and its still at one instant, side by side at one size. The still
 *  is drawn here in the browser; a build script awaits the same two calls. */
function Pair({
  files,
  steps,
  progress,
  rows,
  softWrap = false,
  diagnose,
}: {
  files: CodeFile[]
  steps: CodeStep[]
  progress: number
  rows: number
  softWrap?: boolean
  diagnose?: typeof TS
}) {
  const [svg, setSvg] = React.useState("")
  React.useEffect(() => {
    let live = true
    void editorStill({
      files,
      steps,
      progress,
      rows,
      columns: COLUMNS,
      softWrap,
      diagnose,
      accent: "#e7a13c",
    }).then((screen) => {
      if (live)
        setSvg(
          renderEditorSvg(screen, {
            fontSize: SIZE,
            // The page's Geist Mono, by the name it has here.
            face: { ...GEIST_MONO, family: "var(--font-mono)" },
          }),
        )
    })
    return () => {
      live = false
    }
  }, [files, steps, progress, rows, softWrap, diagnose])
  return (
    <div className="flex flex-wrap gap-6">
      <div
        className="font-mono"
        style={{ fontSize: SIZE, width: `calc(${COLUMNS}ch + 2px)` }}
      >
        <Terminal alt="helix, live" accent="#e7a13c" size={`${SIZE}px`}>
          <TerminalEditor
            files={files}
            steps={steps}
            progress={progress}
            rows={rows}
            softWrap={softWrap}
            diagnose={diagnose}
          />
        </Terminal>
      </div>
      <div
        role="img"
        aria-label="helix, as an SVG still"
        // A still inlined as markup, so the page's own face reaches its text.
        // biome-ignore lint/security/noDangerouslySetInnerHtml: our own renderer's SVG, its text escaped
        dangerouslySetInnerHTML={{ __html: svg }}
      />
    </div>
  )
}

const TYPO_AT =
  (codeTimeline(SOURCES, TYPO, { diagnose: TS }).ends[1] as number) /
  codeTimeline(SOURCES, TYPO, { diagnose: TS }).total

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample
        name="problem"
        title="live, and the still"
        description="the same instant: the DOM on the left, renderEditorSvg on the right"
        with="problem"
      >
        <Pair
          files={SOURCES}
          steps={TYPO}
          progress={TYPO_AT}
          rows={7}
          diagnose={TS}
        />
      </Sample>
      <Sample
        name="wrap"
        title="buffers, soft-wrap, insert"
        description="the bufferline, a long line on two rows behind ↪, the caret a bar mid-word"
        with="wrap"
      >
        <Pair files={NOTES} steps={TYPING} progress={0.6} rows={8} softWrap />
      </Sample>
    </div>
  )
}

the source, every file an install writes

terminal-editor-screen.ts
// HELIX'S SCREEN AS DATA: what terminal-editor shows at an instant of an edit, decided
// once, and drawn by two emitters that cannot disagree: the live editor in the DOM
// (ui/terminal-editor) and the SVG still (`renderEditorSvg`, for a build script, an og
// card, a feature's media). Framework-free by contract: nothing here may import React
// or touch the DOM.
//
//   const screen = await editorStill({ files, steps, progress: 1, columns: 64, rows: 12 })
//   const svg = renderEditorSvg(screen, { fontSize: 16 })
//
// The model decides everything that follows from the text and the screen's size, in
// the terminal's own units, columns and rows: which rows show (the scroll that keeps
// the caret in view), how a long line soft-wraps, each run's ink and marks (the
// undercurl, the selection), the gutter's mark and the message at a line's end, the
// caret, the bufferline, the status line's parts, the command line's words, where a
// popup opens. An emitter only places those cells: the DOM in `ch` and `em`, the SVG
// in px from the face's measured advance.

import {
  type CodeDiagnose,
  type CodeDiagnostic,
  type CodeFile,
  type CodeFrame,
  type CodePopup,
  type CodeSeverity,
  type CodeStep,
  codeFrameAt,
  codeTimeline,
  placeOf,
} from "@/registry/base-nova/lib/code-session"
import {
  type CodeColours,
  type CodePaint,
  codeColours,
  langOf,
  paintRuns,
  runsOf,
} from "@/registry/base-nova/lib/code-tokens"
import {
  ansiColor,
  baselineOf,
  escapeSvg,
  GEIST_MONO,
  LINE_HEIGHT,
  type MonoFace,
  screenInset,
  type TerminalPalette,
  terminalPalette,
  terminalWindowSvg,
} from "@/registry/base-nova/lib/terminal-session"

/** How the status line names each mode, helix's three letters: a selection made to be
 *  changed is normal mode's. */
const MODES = {
  normal: "NOR",
  insert: "INS",
  select: "NOR",
  command: "NOR",
} as const
/** Rows helix keeps between the caret and the screen's foot. */
const SCROLL_OFF = 3
/** Helix's soft-wrap: the indicator a wrapped row opens with, and the longest word it
 *  moves whole to the next row rather than breaking (`max-wrap`). */
export const WRAP_INDICATOR = "↪ "
const MAX_WRAP = 20
/** The terminal's own colour for each severity (its red, yellow, blue, grey), and how
 *  helix's pickers name them. */
export const SEVERITY: Record<CodeSeverity, { ink: string; name: string }> = {
  error: { ink: ansiColor(1), name: "ERROR" },
  warning: { ink: ansiColor(3), name: "WARN" },
  info: { ink: ansiColor(4), name: "INFO" },
  hint: { ink: ansiColor(8), name: "HINT" },
}
export const WORST: CodeSeverity[] = ["error", "warning", "info", "hint"]
/** The rows helix's completion menu and picker show before they scroll. */
export const MENU_ROWS = 8
/** The message at a line's end: four spaces after the line, a little faint. */
export const EOL_GAP = "    "
export const EOL_OPACITY = 0.8
/** The selection: the accent laid over the text at this share. */
export const SELECTION_ALPHA = 0.3

/** A stretch of a screen row in one ink: `color` null is the screen's foreground. */
export interface EditorRun {
  text: string
  color: string | null
  italic: boolean
  bold: boolean
  selected: boolean
  /** The worst diagnostic over it, undercurled in its colour. */
  undercurl: CodeSeverity | null
}

/** A row of the text: a piece of one line of the file, from column `from`. */
export interface EditorRow {
  /** The file's line, from 0. */
  line: number
  from: number
  /** The line's first row (numbered, the gutter's mark) and its last (the message). */
  first: boolean
  last: boolean
  /** The line's length, where a press past its end lands. */
  length: number
  runs: EditorRun[]
  /** The caret's line: its number lit. */
  current: boolean
  /** The line's worst diagnostic: the gutter's mark on its first row, its message
   *  after its last. */
  worst: CodeDiagnostic | null
}

/** One file across the bufferline. */
export interface EditorBuffer {
  path: string
  /** The name shown, `[+]` on unsaved keys. */
  label: string
  front: boolean
}

/** The editor's screen at an instant, in columns and rows. */
export interface EditorScreen {
  palette: TerminalPalette
  /** The screen's width in columns; Infinity until a live editor has measured it. */
  columns: number
  /** The text's rows (the screen's, less the bufferline's), null past the file's end. */
  rows: (EditorRow | null)[]
  /** The line numbers' width in digits; the text starts at `gutter`, past the
   *  diagnostics' column, the numbers and their padding. */
  digits: number
  gutter: number
  /** Null while the bufferline is not shown. */
  buffers: EditorBuffer[] | null
  /** The caret: its row of `rows`, its column in the text (past `gutter`), a bar in
   *  insert and a block otherwise. */
  caret: { row: number; column: number; insert: boolean } | null
  /** A popup open, anchored at a row of `rows` and a column of the text; `below` the
   *  anchor's row (completion, or no room above) or above it. */
  popup: {
    popup: CodePopup
    row: number
    column: number
    below: boolean
  } | null
  /** `space d`'s picker open: the front file's diagnostics, worst first. */
  picker: {
    name: string
    text: string
    diagnostics: CodeDiagnostic[]
  } | null
  status: {
    mode: "NOR" | "INS"
    insert: boolean
    /** The file in front, ` [+]` while it is unsaved. */
    file: string
    /** The errors and the warnings, only those there are. */
    counts: { severity: CodeSeverity; n: number }[]
    /** `1 sel 4:26`. */
    place: string
  }
  /** The command line: what is typed into it, a prompt (its bar after), helix's
   *  answer to the last save, or nothing. */
  command: { text: string; prompt: boolean }
}

export interface EditorScreenOptions {
  palette: TerminalPalette
  /** The front file's colours (lib/code-tokens `codeColours` of the frame's state),
   *  null to draw it plain. */
  colours: CodeColours | null
  /** Rows of the screen above the status and command lines, the bufferline's
   *  included when it shows. */
  rows: number
  /** The screen's whole width in columns, the gutter's included (a soft-wrapped line
   *  wraps in what is left past it); Infinity wraps nothing. */
  columns: number
  bufferline: "always" | "multiple" | "never"
  softWrap: boolean
  /** A text box taking typing: insert, whatever mode the edit is in. */
  insert?: boolean
}

const nameOf = (path: string) => path.split("/").at(-1) as string

type Mark = CodeSeverity | "selection"
type Painted = CodePaint<Mark>[]

/** `runs` from column `a` to `b`. */
function cut(runs: Painted, a: number, b: number): Painted {
  const out: Painted = []
  let at = 0
  for (const r of runs) {
    const end = at + r.text.length
    const from = Math.max(a, at)
    const to = Math.min(b, end)
    if (from < to) out.push({ ...r, text: r.text.slice(from - at, to - at) })
    at = end
  }
  return out
}

/** A line's pieces as helix soft-wraps it in `width` columns: at the last space that
 *  fits (one just past the edge too), unless the word left over is longer than
 *  MAX_WRAP, then at the edge; every row after the first `WRAP_INDICATOR` narrower. */
function wrap(
  runs: Painted,
  width: number,
): { from: number; to: number; runs: Painted }[] {
  const text = runs.map((r) => r.text).join("")
  const pieces: { from: number; to: number; runs: Painted }[] = []
  let from = 0
  do {
    const room = Math.max(
      1,
      pieces.length ? width - WRAP_INDICATOR.length : width,
    )
    let to = Math.min(text.length, from + room)
    if (to < text.length) {
      const space = text.lastIndexOf(" ", to)
      if (space >= from && to - space - 1 <= MAX_WRAP) to = space + 1
    }
    pieces.push({ from, to, runs: cut(runs, from, to) })
    from = to
  } while (from < text.length)
  return pieces
}

/** The screen of `f`, the edit's frame at an instant (lib/code-session
 *  `codeFrameAt`). */
export function editorScreen(
  f: CodeFrame,
  {
    palette,
    colours,
    rows,
    columns,
    bufferline,
    softWrap,
    insert: typing = false,
  }: EditorScreenOptions,
): EditorScreen {
  if (!(columns > 0))
    throw new Error(`terminal-editor-screen: ${columns} columns`)
  const path = f.active ?? "[scratch]"
  const lines = runsOf(f.text, colours, f.origin)
  const place = placeOf(f.text, f.caret)
  const digits = Math.max(3, String(lines.length).length)
  const gutter = digits + 3
  const shown =
    bufferline === "always" || (bufferline === "multiple" && f.open.length > 1)
  const textRows = rows - (shown ? 1 : 0)
  if (textRows < 1)
    throw new Error(`terminal-editor-screen: ${rows} rows hold no text`)
  const ranges: { from: number; to: number; mark: Mark }[] = [
    ...f.diagnostics.map((d) => ({ from: d.from, to: d.to, mark: d.severity })),
    ...(f.selection ? [{ ...f.selection, mark: "selection" as const }] : []),
  ]
  const starts: number[] = []
  let at = 0
  for (const runs of lines) {
    starts.push(at)
    at += runs.reduce((n, r) => n + r.text.length, 0) + 1
  }
  const lineOf = (offset: number) => starts.findLastIndex((s) => s <= offset)
  const worstOf = (i: number) =>
    f.diagnostics
      .filter((d) => lineOf(d.from) === i)
      .sort(
        (a, b) => WORST.indexOf(a.severity) - WORST.indexOf(b.severity),
      )[0] ?? null
  // Every row of the file: each line once, or soft-wrapped across as many as it needs.
  const all: EditorRow[] = lines.flatMap((runs, i) => {
    const painted = paintRuns(runs, starts[i] as number, ranges)
    const length = runs.reduce((n, r) => n + r.text.length, 0)
    const pieces =
      softWrap && Number.isFinite(columns)
        ? wrap(painted, columns - gutter)
        : [{ from: 0, to: length, runs: painted }]
    const worst = worstOf(i)
    return pieces.map((p, k) => ({
      line: i,
      from: p.from,
      first: k === 0,
      last: k === pieces.length - 1,
      length,
      current: i === place.line - 1,
      worst,
      runs: p.runs.map((r) => ({
        text: r.text,
        color: r.ink?.dark ?? null,
        italic: r.ink?.italic ?? false,
        bold: r.ink?.bold ?? false,
        selected: r.marks.includes("selection"),
        undercurl: WORST.find((s) => r.marks.includes(s)) ?? null,
      })),
    }))
  })
  // The row a place of the text is on: the last piece of its line starting at or
  // before its column.
  const rowOf = (p: { line: number; column: number }) =>
    all.findLastIndex((s) => s.line === p.line - 1 && s.from <= p.column - 1)
  const caretRow = rowOf(place)
  const top = Math.max(0, caretRow - textRows + SCROLL_OFF)
  const columnIn = (r: number, p: { column: number }) => {
    const s = all[r]
    return (
      (s && !s.first ? WRAP_INDICATOR.length : 0) +
      p.column -
      1 -
      (s?.from ?? 0)
    )
  }
  const mode = typing ? "insert" : f.mode
  const insert = mode === "insert"
  /** A popup at its anchor's row and column: below it for the completion menu, or
   *  when the rows above are too few. */
  const placed = (popup: CodePopup) => {
    const p = placeOf(f.text, popup.at)
    const r = rowOf(p)
    const row = (r < 0 ? caretRow : r) - top
    return {
      popup,
      row,
      column: columnIn(r, p),
      below: popup.kind === "completion" || row < 4,
    }
  }
  const command = f.prompt
    ? `${f.prompt.label}: ${f.prompt.text}`
    : f.command
      ? f.command
      : f.saved && f.saved.path === f.active
        ? `'${f.saved.path}' written, ${f.text.replace(/\n$/, "").split("\n").length}L ${new TextEncoder().encode(f.text).length}B`
        : ""
  return {
    palette,
    columns,
    rows: Array.from({ length: textRows }, (_, r) => all[top + r] ?? null),
    digits,
    gutter,
    buffers: shown
      ? f.open.map((p) => ({
          path: p,
          label: `${nameOf(p)}${f.dirty.includes(p) ? "[+]" : ""}`,
          front: p === f.active,
        }))
      : null,
    caret:
      caretRow < 0
        ? null
        : { row: caretRow - top, column: columnIn(caretRow, place), insert },
    popup: f.popup ? placed(f.popup) : null,
    picker:
      f.panel === "problems"
        ? {
            name: nameOf(path),
            text: f.text,
            diagnostics: [...f.diagnostics].sort(
              (a, b) => WORST.indexOf(a.severity) - WORST.indexOf(b.severity),
            ),
          }
        : null,
    status: {
      mode: MODES[mode],
      insert,
      file: `${path}${f.active && f.dirty.includes(f.active) ? " [+]" : ""}`,
      counts: WORST.slice(0, 2)
        .map((severity) => ({
          severity,
          n: f.problems.reduce(
            (k, p) =>
              k + p.diagnostics.filter((d) => d.severity === severity).length,
            0,
          ),
        }))
        .filter((c) => c.n > 0),
      place: `1 sel ${place.line}:${place.column}`,
    },
    command: { text: command, prompt: f.prompt !== null },
  }
}

// ── the still ──

export interface EditorStillOptions
  extends Omit<
    EditorScreenOptions,
    "palette" | "colours" | "bufferline" | "softWrap"
  > {
  files: CodeFile[]
  steps: CodeStep[]
  /** The instant drawn, 0..1 of the edit: the live editor's `progress`. */
  progress: number
  diagnose?: CodeDiagnose
  duration?: number
  /** The terminal's accent (lib/terminal-session `terminalPalette`). */
  accent?: string
  bufferline?: EditorScreenOptions["bufferline"]
  softWrap?: boolean
}

/** The screen at `progress` of an edit, coloured: what the live editor draws at that
 *  `progress`, for `renderEditorSvg`. Async only for the colours (Shiki, or the
 *  language's own tokens, loaded on the first call). */
export async function editorStill({
  files,
  steps,
  progress,
  diagnose,
  duration,
  accent,
  bufferline = "multiple",
  softWrap = false,
  ...screen
}: EditorStillOptions): Promise<EditorScreen> {
  if (!(progress >= 0 && progress <= 1))
    throw new Error(`terminal-editor-screen: progress ${progress} is not 0..1`)
  if (!Number.isFinite(screen.columns))
    throw new Error("terminal-editor-screen: a still is given its columns")
  const timeline = codeTimeline(files, steps, { duration, diagnose })
  const f = codeFrameAt(timeline, progress * timeline.total)
  const state = timeline.states[f.state]
  const colours = state
    ? await codeColours(state.text, langOf(state.path))
    : null
  return editorScreen(f, {
    ...screen,
    palette: terminalPalette(accent),
    colours,
    bufferline,
    softWrap,
  })
}

/** The screen's size in em of its type: the bufferline, the text's top padding, its
 *  rows, the status and command lines. The window adds its bar (terminal-session
 *  `BAR_EM`) and its hairline. */
export const screenEm = (screen: EditorScreen) =>
  (screen.buffers ? LINE_HEIGHT : 0) +
  TEXT_TOP +
  (screen.rows.length + 2) * LINE_HEIGHT
/** The padding above the text's first row, em. */
export const TEXT_TOP = 0.5

export interface EditorSvgOptions {
  /** The type, px; the whole window scales with it. */
  fontSize?: number
  face?: MonoFace
  /** The status and command lines, drawn by default. A still seen too small to read
   *  them (a link preview's thumbnail) leaves both out, the text's own padding under
   *  its last row as above its first. */
  statusLines?: boolean
}

/** The screen as the terminal's window in SVG, at its own size: `columns` wide in the
 *  face's advance, `screenEm` tall plus the window's bar. Throws on what it does not
 *  draw yet (a popup, the picker) and on a screen with no width: never a still that
 *  quietly differs from the live one. */
export function renderEditorSvg(
  screen: EditorScreen,
  {
    fontSize = 16,
    face = GEIST_MONO,
    statusLines = true,
  }: EditorSvgOptions = {},
): string {
  const { palette, columns } = screen
  if (!Number.isFinite(columns))
    throw new Error("renderEditorSvg: the screen has no width; give it columns")
  if (screen.popup)
    throw new Error(
      `renderEditorSvg: a ${screen.popup.popup.kind} popup is not drawn as a still yet`,
    )
  if (screen.picker)
    throw new Error(
      "renderEditorSvg: the diagnostics picker is not drawn as a still yet",
    )
  const ch = face.ch * fontSize
  const lh = LINE_HEIGHT * fontSize
  const base = baselineOf(face) * fontSize
  const out: string[] = []
  /** Text from column `col` of the row whose top is `y`, cut at the screen's edge. */
  // Every word is placed at its own column, so the grid holds whatever a renderer does
  // with spaces (a browser collapses them in an inlined SVG, `white-space` or not) and
  // whatever its face's advance.
  const put = (col: number, y: number, text: string, attrs: string): number => {
    const shown = [...text].slice(0, Math.max(0, columns - col))
    const s = shown.join("")
    for (const m of s.matchAll(/\S+/g)) {
      const at = col + [...s.slice(0, m.index)].length
      out.push(
        `<text x="${at * ch}" y="${y + base}" ${attrs}>${escapeSvg(m[0])}</text>`,
      )
    }
    return col + shown.length
  }
  const fill = (
    col: number,
    y: number,
    width: number,
    colour: string,
    extra = "",
  ) => {
    const w = Math.min(width, columns - col)
    if (w > 0)
      out.push(
        `<rect x="${col * ch}" y="${y}" width="${w * ch}" height="${lh}" fill="${colour}"${extra}/>`,
      )
  }
  // A bar (the bufferline, the status line) through the window's inset to its edges,
  // as the live one reaches (terminal-editor's EDGE_TO_EDGE).
  const inset = screenInset(fontSize, face)
  const bar = (y: number) =>
    out.push(
      `<rect x="${-inset}" y="${y}" width="${columns * ch + 2 * inset}" height="${lh}" fill="${palette.bar}"/>`,
    )
  let y = 0
  if (screen.buffers) {
    bar(y)
    let col = 0
    for (const b of screen.buffers) {
      const width = b.label.length + 2
      if (b.front) fill(col, y, width, palette.dot)
      put(
        col + 1,
        y,
        b.label,
        `fill="${b.front ? palette.command : palette.muted}"`,
      )
      col += width
    }
    y += lh
  }
  y += TEXT_TOP * fontSize
  screen.rows.forEach((row, r) => {
    const top = y + r * lh
    if (!row) return
    if (row.first && row.worst)
      put(0, top, "●", `fill="${SEVERITY[row.worst.severity].ink}"`)
    if (row.first) {
      const n = String(row.line + 1)
      put(
        screen.digits + 2 - n.length,
        top,
        n,
        `fill="${row.current ? palette.accent : palette.prompt}"`,
      )
    }
    let col = screen.gutter
    if (!row.first)
      col = put(col, top, WRAP_INDICATOR, `fill="${palette.prompt}"`)
    for (const run of row.runs) {
      const width = [...run.text].length
      if (run.selected)
        fill(
          col,
          top,
          width,
          palette.accent,
          ` fill-opacity="${SELECTION_ALPHA}"`,
        )
      const attrs = [
        `fill="${run.color ?? palette.command}"`,
        run.italic && `font-style="italic"`,
        run.bold && `font-weight="700"`,
      ]
        .filter(Boolean)
        .join(" ")
      const end = put(col, top, run.text, attrs)
      if (run.undercurl && end > col)
        out.push(
          undercurl(
            col * ch,
            end * ch,
            top + base,
            fontSize,
            SEVERITY[run.undercurl].ink,
          ),
        )
      col += width
    }
    if (row.last && row.worst)
      put(
        col,
        top,
        `${EOL_GAP}${row.worst.message}`,
        `fill="${SEVERITY[row.worst.severity].ink}" fill-opacity="${EOL_OPACITY}"`,
      )
  })
  if (screen.caret) {
    const top = y + screen.caret.row * lh
    const x = (screen.gutter + screen.caret.column) * ch
    out.push(
      screen.caret.insert
        ? `<rect x="${x}" y="${top}" width="${(2 / 13) * fontSize}" height="${lh}" fill="${palette.accent}"/>`
        : `<rect x="${x}" y="${top}" width="${ch}" height="${lh}" fill="${palette.accent}" style="mix-blend-mode: difference"/>`,
    )
  }
  y += screen.rows.length * lh
  if (!statusLines)
    return terminalWindowSvg(
      palette,
      { width: columns * ch, height: y + TEXT_TOP * fontSize, fontSize, face },
      out.join(""),
    )
  // The status line: the mode, the file, then at the right the counts and the place,
  // a column between each, as the live one lays them in a row.
  const { status } = screen
  bar(y)
  fill(
    0,
    y,
    status.mode.length + 2,
    status.insert ? palette.command : palette.accent,
  )
  put(1, y, status.mode, `fill="${palette.bg}" font-weight="700"`)
  const right = [
    ...status.counts.map((c) => ({
      text: `● ${c.n}`,
      ink: SEVERITY[c.severity].ink,
    })),
    { text: status.place, ink: palette.muted },
  ]
  let end = columns - 1
  const placed = right.reverse().map((s) => {
    const col = end - s.text.length
    end = col - 1
    return { ...s, col }
  })
  const fileAt = status.mode.length + 3
  const room = end - fileAt
  const file =
    status.file.length <= room
      ? status.file
      : `${status.file.slice(0, Math.max(0, room - 1))}…`
  put(fileAt, y, file, `fill="${palette.command}"`)
  for (const s of placed) put(s.col, y, s.text, `fill="${s.ink}"`)
  y += lh
  const typed = put(1, y, screen.command.text, `fill="${palette.muted}"`)
  if (screen.command.prompt) fill(typed, y, 1, palette.accent)
  y += lh
  return terminalWindowSvg(
    palette,
    { width: columns * ch, height: y, fontSize, face },
    out.join(""),
  )
}

/** A wavy line under a run, as Chrome draws CSS's `underline wavy` in Geist Mono: at
 *  the face's underline (0.1 em below the baseline, 0.05 em thick, from its `post`
 *  table, a pixel at the least), a wave half a wavelength of 0.27 em, its curve's
 *  control 0.23 em off the line (measured against Chrome's at 13px: 3px peak to
 *  peak). */
function undercurl(
  x0: number,
  x1: number,
  baseline: number,
  fontSize: number,
  ink: string,
): string {
  const amp = fontSize * 0.23
  const y = baseline + fontSize * 0.1 + amp / 2
  const half = fontSize * 0.27
  let d = `M${x0} ${y}`
  for (let x = x0, k = 0; x < x1; x += half, k++)
    d += `Q${Math.min(x1, x + half / 2)} ${y + (k % 2 ? amp : -amp)} ${Math.min(x1, x + half)} ${y}`
  return `<path d="${d}" fill="none" stroke="${ink}" stroke-width="${Math.max(1, fontSize * 0.05)}"/>`
}