Theme
Design your own
On this page (9)
Component Skill — components/terminal/component-skill.md

Native basis

A <div class="terminal">; the runtime opens xterm.js in a .terminal-screen inside it. A <script type="application/json" class="terminal-script"> child replays a session.

Web Platform APIs

import()ResizeObserverIntersectionObserverAbortControllerClipboard APICustomEvent

Classes

.terminal.terminal-screen.terminal-script.terminal-error

Upstream

@xterm/xterm@6.0.0The official terminal emulator (xtermjs/xterm.js, MIT) - lib/xterm.mjs and css/xterm.css via jsDelivr, pinnedaddon-fit, -clipboard, -serialize, -web-linksLoaded with it: the screen fits its box, OSC 52 reaches the clipboard, the buffer serializes to HTML or VT, URLs are linksconfigure({ urls })Self-host: point the loader at your copies of the same builds

Accessibility

• aria-label on the terminal names it and xterm's input

• aria-busy="true" while a command or a replay runs

• data-screen-reader turns on xterm's screen-reader mode (a live region, the rows as text)

• Tab on an empty line moves the focus on, Shift+Tab back - the terminal never traps it

§A shell

Click into it and type help. The commands come from the page: a handler returns its output - a string, lines, or a promise of them - or writes through its context. Up and Down walk the history, Tab completes a name, Ctrl+C aborts count. A cancelable terminal-command event comes before every line.

§A replayed session

A JSON script of steps: each command is typed at the prompt and answered by its output. data-autoplay plays it when the terminal first shows, after that many ms; a key press finishes it at once, then the prompt takes input.

§DOS

data-variant="dos" is a PC's text mode: the 16 colours on black, square corners, an underline cursor. data-not-found answers an unknown command; commands are found as typed, else in lower case, so DIR finds dir.

§Output from the page

data-readonly is output only - no prompt, no input. The page writes lines with writeln() and colours them with tone(); text() reads back what the screen shows, serialize() returns the buffer as HTML or VT sequences.

§Muted and sizes

data-variant="muted" takes the page's muted surface and text - light in light mode - with the ANSI colours mixed toward the text. data-size sm and lg set the font size; the rows follow.

§States

Named states via the shared State API, bound on each terminal:

  • default - a prompt that takes input (config.prompt sets data-prompt)
  • running - a command runs: setState('running', { command }) types it and runs it; without a command the script replays. It lands in default
  • readonly - output only: no prompt, no input (data-readonly)

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/terminal-{state}.png.

Machine contract - verified against terminal.schema.json by bun run verify:

StateTypeValuesDefaultDescription
promptstring—"$ "The prompt (data-prompt) - a waiting prompt changes at once.
runningbooleantrue, falsefalseA command runs (aria-busy) - checked, the script replays; it lands in default when done.
readonlybooleantrue, falsefalseOutput only: no prompt, no input (data-readonly).

§API

Generated from terminal.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type TerminalState = 'default' | 'running' | 'readonly' - setState(name, config) takes the config of the state it names.

StateDescription
default
A prompt that takes input.
Config fieldTypeDescription
prompt?stringthe prompt text (written to data-prompt)
running
A command runs (setState runs it as if typed) - or, without one, the script replays. Lands in default.
Config fieldTypeDescription
command?stringthe command line to run
readonly
Output only: no prompt, no input (data-readonly).

No config.

Every element

MemberDescription
el.api.setState<S extends TerminalState>(name: S, config?: TerminalStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?TerminalStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: TerminalState; config: TerminalStateConfigs[TerminalState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.terminalApi.setState<S extends TerminalState>(el: HTMLElement, name: S, config?: TerminalStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?TerminalStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.terminalApi.getState(el: HTMLElement): { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.terminalApi.render(state: { name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: TerminalState; config: TerminalStateConfigs[TerminalState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.terminalApi.store(el: HTMLElement): Store<{ name: TerminalState; config: TerminalStateConfigs[TerminalState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: TerminalState; config: TerminalStateConfigs[TerminalState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.terminalApi.commit<S extends TerminalState>(el: HTMLElement, name: S, config?: TerminalStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?TerminalStateConfigs[S]its config
df$.shadcn.terminalStates: TerminalState[]The declared states, 'default' first: default, running, readonly.

df$.shadcn.terminal

MemberDescription
load(): Promise<void>
Imports the official xterm.js build, its addons and its stylesheet (the first terminal does it by itself).

Returns Promise<void> - once they have loaded

configure(options: { urls?: Partial<TerminalUrls> }): void
Where the vendor files come from - call it before the first terminal opens to self-host them.
ArgumentTypeDescription
options{ urls?: Partial<TerminalUrls> }the URLs to use instead of the pinned jsDelivr ones (any subset)
write(target: string | HTMLElement, text: string): Promise<void>
Writes text: above the prompt while one waits (the line being typed stays), else where the cursor is.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
textstringthe text ("\n" starts a new line; VT sequences and tone() work)

Returns Promise<void> - once it is on screen

writeln(target: string | HTMLElement, text: string = ''): Promise<void>
Writes a line - write() with a new line after it.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
textstring = ''the line

Returns Promise<void> - once it is on screen

clear(target: string | HTMLElement): Promise<void>
Clears the screen and the scrollback; a waiting prompt is drawn again at the top.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector

Returns Promise<void> - once it is clear

run(target: string | HTMLElement, line: string): Promise<void>
Runs a command line as if it were typed at the prompt (the State API's running state does the same).
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
linestringthe command line

Returns Promise<void> - once the command has run and a prompt waits again

play(target: string | HTMLElement, steps?: TerminalStep[]): Promise<void>
Replays a session: commands typed at the prompt and their output (a key finishes it at once).
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
steps?TerminalStep[]the steps (default: the element's script.terminal-script)

Returns Promise<void> - once the session has played

commands(target: 'global' | string | HTMLElement, commands: Record<string, TerminalCommand | TerminalCommandSpec>): (() => void)
Adds commands - to one terminal, or to every terminal on the page with 'global'. A name the terminal knows already (a built-in too) takes the new command.
ArgumentTypeDescription
target'global' | string | HTMLElement'global', or the .terminal element, its id or a selector
commandsRecord<string, TerminalCommand | TerminalCommandSpec>each name with its handler, or with { run, description, hidden }

Returns (() => void) - a function that removes them again

prompt(target: string | HTMLElement, text: string): void
Sets the prompt (data-prompt) - a waiting prompt changes at once.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
textstringthe prompt text
text(target: string | HTMLElement): string
The screen and the scrollback as plain text (lines trimmed at the end, trailing empty lines left out).
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector

Returns string - the text ('' before the terminal has opened)

serialize(target: string | HTMLElement, format: 'html' | 'vt' = 'html'): string
The screen and the scrollback serialized by the serialize addon - as HTML (colours inline) or as VT sequences.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
format'html' | 'vt' = 'html''html' (default) or 'vt'

Returns string - the serialized buffer ('' before the terminal has opened)

focus(target: string | HTMLElement): void
Focuses the terminal's input.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector
tone(text: string, tone: TerminalTone): string
Text in a tone, as the VT sequences xterm draws in the theme's colours.
ArgumentTypeDescription
textstringthe text
toneTerminalTonesuccess, warning, info, destructive, muted or bold

Returns string - the text wrapped in the tone's SGR sequence (unchanged for an unknown tone)

xterm(target: string | HTMLElement): Promise<XtermLike>
The xterm.js Terminal behind the element - for what this API does not cover.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector

Returns Promise<XtermLike> - the instance, once the terminal has opened

theme(target: string | HTMLElement): Record<string, string>
The xterm theme the element's --terminal-* custom properties describe.
ArgumentTypeDescription
targetstring | HTMLElementthe .terminal element, its id or a selector

Returns Record<string, string> - xterm's theme (colours as rgba()), {} for no terminal

url: string
The pinned official xterm.js build the component loads (always this exact version).

Events

EventDescription
terminal-command
Fires when a command line is submitted, before it runs - preventDefault() keeps the terminal from running it (the page answers through write()).

detail: TerminalCommandDetail

FieldTypeDescription
linestringthe whole line
namestringthe command's name (the first word)
argsstring[]the words after it
terminal-ready
Fires once the xterm is open and sized - its columns and rows.

detail: TerminalReadyDetail

FieldTypeDescription
colsnumbercolumns
rowsnumberrows

Types

TypeDescription
TerminalCommand
A command: what it returns (text, lines, or a promise of them) is written after it.

= (context: TerminalCommandContext,) => string | string[] | void | Promise<string | string[] | void>

TerminalCommandContext
What a command handler gets: its words, the line, ways to write, and a signal that Ctrl+C aborts.
FieldTypeDescription
namestringthe command's name as typed (the first word)
argsstring[]the words after the name ("quoted words" stay one)
linestringthe whole line as typed
terminalHTMLElementthe terminal element
write(text: string) => voidwrites text - "\n" starts a new line
writeln(text?: string) => voidwrites a line
signalAbortSignalaborted by Ctrl+C, setState('default') or the element leaving the page
TerminalCommandDetail
Fires when a command line is submitted, before it runs.
FieldTypeDescription
linestringthe whole line
namestringthe command's name (the first word)
argsstring[]the words after it
TerminalCommandSpec
A command with a description - help lists it.
FieldTypeDescription
runTerminalCommandruns the command
description?stringone line for help
hidden?booleanleft out of help and Tab completion
TerminalReadyDetail
Fires once the xterm is open and sized.
FieldTypeDescription
colsnumbercolumns
rowsnumberrows
TerminalStep
One step of a replayed session (script.terminal-script holds an array of them).
FieldTypeDescription
command?stringa command line, typed at the prompt and run - through `output` when it is given, else through the command
output?string | string[]text written (after the command, or alone); an array writes one line each
tone?TerminalTonethe output's tone
wait?numberms to wait before the step
typing?booleanfalse: the command appears at once instead of being typed
TerminalTone
A tone of the system - the same words as the Code Mockup's line tones.

= 'success' | 'warning' | 'info' | 'destructive' | 'muted' | 'bold'

TerminalUrls
Where the vendor files come from - the pinned jsDelivr builds, or self-hosted copies of the same versions.
FieldTypeDescription
xtermstringthe xterm.js ES module (exports Terminal)
cssstringxterm's stylesheet
fitstringthe fit addon - sizes the terminal to its box
clipboardstringthe clipboard addon - OSC 52: programs reach the browser's clipboard
serializestringthe serialize addon - the buffer as VT sequences or HTML
webLinksstringthe web-links addon - URLs in the output become links
XtermBuffer
An xterm buffer: the scrollback and the screen.
FieldTypeDescription
lengthnumberlines in the buffer (scrollback + screen)
cursorXnumberthe cursor's column
getLine(y: number) => XtermBufferLine | undefinedthe line at y (0 = the oldest in the scrollback)
XtermBufferLine
One line of an xterm buffer.
FieldTypeDescription
translateToString(trimRight?: boolean) => stringthe line as text; trimRight leaves out the cells never written at its end
XtermDisposable
What xterm returns for a listener - disposing it removes the listener.
FieldTypeDescription
dispose() => voidremoves the listener
XtermLike
The official xterm.js Terminal - the members this component uses (the instance has every other one too).
FieldTypeDescription
colsnumbercolumns
rowsnumberrows
optionsRecord<string, unknown>live options (theme, fonts, cursor ...)
buffer{ active: XtermBuffer }the screen buffer
open(parent: HTMLElement) => voidopens the terminal in an element
write(data: string, callback?: () => void) => voidwrites data (VT sequences allowed); the callback runs once it is parsed
clear() => voidclears the scrollback and the screen but the cursor line
focus() => voidfocuses the terminal
resize(cols: number, rows: number) => voidresizes to cols x rows
loadAddon(addon: unknown) => voidloads an addon
onData(listener: (data: string) => void) => XtermDisposablelistens to typed / pasted data
attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean) => voiddecides per key event whether xterm handles it (false: the browser does)
scrollToBottom() => voidscrolls to the newest line
dispose() => voiddisposes the terminal

§CSS view file

/* -- Terminal component ------------------------------------------------- */
/* A terminal emulator (xterm.js) in the Code Mockup's window. xterm paints
   its own cells, so its colours are custom properties the runtime reads:
   --terminal-background / -foreground / -cursor / -cursor-accent /
   -selection and the sixteen ANSI colours (--terminal-red ...
   --terminal-bright-white). A variant sets them; a theme or a page can set
   any of them. --terminal-cursor-style (block, bar, underline) and
   --terminal-line-height (a factor) are read the same way. */
@layer components {
  /* xterm.js names its own element "terminal xterm" too - every rule here skips it */
  .terminal:not(.xterm) {
    /* the default: a dark screen in every theme, the Code Mockup's tones */
    --terminal-background: oklch(0.21 0.006 286);
    --terminal-foreground: oklch(0.94 0 0);
    --terminal-cursor: var(--terminal-foreground);
    --terminal-cursor-accent: var(--terminal-background);
    --terminal-selection: color-mix(in oklch, var(--terminal-foreground) 28%, transparent);
    --terminal-black: oklch(0.32 0.01 286);
    --terminal-red: oklch(0.72 0.18 25);
    --terminal-green: oklch(0.8 0.16 150);
    --terminal-yellow: oklch(0.84 0.15 80);
    --terminal-blue: oklch(0.76 0.13 245);
    --terminal-magenta: oklch(0.76 0.15 320);
    --terminal-cyan: oklch(0.8 0.11 200);
    --terminal-white: oklch(0.86 0 0);
    --terminal-bright-black: oklch(0.62 0.01 286);
    --terminal-bright-red: oklch(0.8 0.15 25);
    --terminal-bright-green: oklch(0.88 0.15 150);
    --terminal-bright-yellow: oklch(0.91 0.13 90);
    --terminal-bright-blue: oklch(0.84 0.11 245);
    --terminal-bright-magenta: oklch(0.84 0.13 320);
    --terminal-bright-cyan: oklch(0.88 0.1 200);
    --terminal-bright-white: oklch(0.99 0 0);
    --terminal-cursor-style: block;
    --terminal-line-height: 1.25;
    --terminal-height: 20rem;
    --terminal-padding: 0.5rem 1rem 0.75rem;
    position: relative;
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    min-width: 0;
    max-width: 100%;
    block-size: var(--terminal-height);
    padding-block-start: 2.5rem;
    border-radius: var(--radius-xl);
    background: var(--terminal-background);
    color: var(--terminal-foreground);
    font-family: var(--font-mono);
    font-size: 0.875rem;
    text-align: start;
    /* a terminal reads left to right inside an RTL page too */
    direction: ltr;
    unicode-bidi: isolate;
    overflow: hidden;
    /* -- Window chrome, as the Code Mockup's: three dots; data-chrome="mac"
       colours them, data-chrome="none" drops the bar --------------------- */
    &::before {
      content: "";
      position: absolute;
      inset-block-start: 1rem;
      inset-inline-start: 1.25rem;
      width: 0.75rem;
      height: 0.75rem;
      border-radius: 9999px;
      color: color-mix(in oklch, var(--terminal-foreground) 60%, var(--terminal-background));
      background: currentColor;
      opacity: 0.45;
      box-shadow:
        1.25rem 0 0 currentColor,
        2.5rem 0 0 currentColor;
    }
    &[data-chrome="mac"]::before {
      opacity: 1;
      background: oklch(0.68 0.2 25);
      box-shadow:
        1.25rem 0 0 oklch(0.82 0.16 85),
        2.5rem 0 0 oklch(0.74 0.17 145);
    }
    &[data-chrome="none"] {
      padding-block-start: 0;
    }
    &[data-chrome="none"]::before {
      display: none;
    }
    /* a title in the bar: data-title="bash - 80x24" */
    &[data-title]:not([data-chrome="none"])::after {
      content: attr(data-title);
      position: absolute;
      inset-block-start: 0.75rem;
      inset-inline: 4.5rem;
      font-family: var(--font-sans);
      font-size: 0.75rem;
      line-height: 1.25rem;
      text-align: center;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      color: color-mix(in oklch, var(--terminal-foreground) 65%, var(--terminal-background));
    }
    /* data-rows: as high as that many rows - the screen sizes the box */
    &[data-rows] {
      block-size: auto;
    }
    &:focus-within {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    /* -- Sizes ------------------------------------------------------------ */
    &[data-size="sm"] {
      font-size: 0.75rem;
    }
    &[data-size="lg"] {
      font-size: 1rem;
    }
    /* -- Variants --------------------------------------------------------- */
    /* muted: the page's own surface - light in light mode; the ANSI colours
       are mixed toward the text so they keep their contrast in both */
    &[data-variant="muted"] {
      --terminal-background: var(--muted);
      --terminal-foreground: var(--foreground);
      --terminal-black: var(--foreground);
      --terminal-red: color-mix(in oklch, var(--foreground) 30%, oklch(0.6 0.2 25));
      --terminal-green: color-mix(in oklch, var(--foreground) 35%, oklch(0.62 0.16 150));
      --terminal-yellow: color-mix(in oklch, var(--foreground) 40%, oklch(0.7 0.15 75));
      --terminal-blue: color-mix(in oklch, var(--foreground) 30%, oklch(0.6 0.16 255));
      --terminal-magenta: color-mix(in oklch, var(--foreground) 30%, oklch(0.6 0.18 320));
      --terminal-cyan: color-mix(in oklch, var(--foreground) 35%, oklch(0.62 0.11 200));
      --terminal-white: var(--muted-foreground);
      --terminal-bright-black: var(--muted-foreground);
      --terminal-bright-red: var(--terminal-red);
      --terminal-bright-green: var(--terminal-green);
      --terminal-bright-yellow: var(--terminal-yellow);
      --terminal-bright-blue: var(--terminal-blue);
      --terminal-bright-magenta: var(--terminal-magenta);
      --terminal-bright-cyan: var(--terminal-cyan);
      --terminal-bright-white: var(--foreground);
      border: 1px solid var(--border);
    }
    /* dos: the 16 colours of a PC's text mode on black, square, an
       underline cursor */
    &[data-variant="dos"] {
      --terminal-background: #000000;
      --terminal-foreground: #aaaaaa;
      --terminal-cursor: #aaaaaa;
      --terminal-selection: rgb(170 170 170 / 0.35);
      --terminal-black: #000000;
      --terminal-red: #aa0000;
      --terminal-green: #00aa00;
      --terminal-yellow: #aa5500;
      --terminal-blue: #0000aa;
      --terminal-magenta: #aa00aa;
      --terminal-cyan: #00aaaa;
      --terminal-white: #aaaaaa;
      --terminal-bright-black: #555555;
      --terminal-bright-red: #ff5555;
      --terminal-bright-green: #55ff55;
      --terminal-bright-yellow: #ffff55;
      --terminal-bright-blue: #5555ff;
      --terminal-bright-magenta: #ff55ff;
      --terminal-bright-cyan: #55ffff;
      --terminal-bright-white: #ffffff;
      --terminal-cursor-style: underline;
      --terminal-line-height: 1.1;
      border-radius: 0;
    }
  }
  /* the screen xterm opens in (the runtime adds it) - the fit addon measures
     its content box */
  .terminal-screen {
    flex: 1 1 0px;
    box-sizing: content-box;
    min-block-size: 0;
    min-inline-size: 0;
    padding: var(--terminal-padding);
    .terminal[data-rows] > & {
      flex: none;
    }
  }
  /* the vendor build failed to load: the reason, in place of the screen */
  .terminal-error {
    display: block;
    font-family: var(--font-sans);
    font-size: 0.8125rem;
    color: var(--terminal-red);
  }
  /* -- Accessibility ------------------------------------------------------ */
  /* reduced motion: the runtime stops the cursor's blink and the replay's typing (the lines appear at once) */
  @media (prefers-reduced-motion: reduce) {
    .terminal-screen {
      scroll-behavior: auto;
    }
  }
  @media (prefers-contrast: more) {
    .terminal:not(.xterm) {
      --terminal-foreground: oklch(1 0 0);
      --terminal-bright-black: oklch(0.8 0 0);
      border: 1px solid var(--terminal-foreground);
    }
    .terminal:not(.xterm)[data-variant="muted"] {
      --terminal-foreground: var(--foreground);
      --terminal-bright-black: var(--foreground);
      border-color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    /* the runtime reads these: the system colours, converted like any other */
    .terminal:not(.xterm),
    .terminal:not(.xterm)[data-variant] {
      --terminal-background: Canvas;
      --terminal-foreground: CanvasText;
      --terminal-cursor: CanvasText;
      --terminal-cursor-accent: Canvas;
      --terminal-selection: Highlight;
      --terminal-black: Canvas;
      --terminal-red: CanvasText;
      --terminal-green: CanvasText;
      --terminal-yellow: CanvasText;
      --terminal-blue: CanvasText;
      --terminal-magenta: CanvasText;
      --terminal-cyan: CanvasText;
      --terminal-white: CanvasText;
      --terminal-bright-black: CanvasText;
      --terminal-bright-red: CanvasText;
      --terminal-bright-green: CanvasText;
      --terminal-bright-yellow: CanvasText;
      --terminal-bright-blue: CanvasText;
      --terminal-bright-magenta: CanvasText;
      --terminal-bright-cyan: CanvasText;
      --terminal-bright-white: CanvasText;
      border: 1px solid CanvasText;
      forced-color-adjust: none;
    }
  }
}

§JavaScript view file

// -- Terminal ----------------------------------------------------------------------------------------
// A terminal emulator over the OFFICIAL xterm.js (xtermjs/xterm.js, MIT) with its fit, clipboard, serialize and
// web-links addons - zero xterm bytes ship here. One <div class="terminal"> is the whole markup; the runtime opens
// an xterm in a .terminal-screen it adds (data-ce-chrome) and gives it a small shell: a line editor (arrows, Home /
// End, history, Tab completion, Ctrl+C, Ctrl+L, Ctrl+U), a prompt (data-prompt) and commands - built-in help, clear,
// echo and history, the page's own through df$.shadcn.terminal.commands(). A script child (script.terminal-script,
// JSON steps) replays a session: commands typed at the prompt and their output - data-autoplay plays it when the
// terminal first comes into view, a key press finishes it at once.
//
// - Lazy: a page without a terminal never requests xterm. The first terminal imports the pinned ESM builds and
//   links xterm's stylesheet ONCE (PINNED; configure({ urls }) self-hosts them).
// - Themed: xterm paints its own cells, so its theme is read from the element's --terminal-* custom properties
//   (the variants set them from the tokens or a fixed palette) through the browser's own colour conversion; a
//   theme change (dark mode, the theme switcher, defuss-theme-change) re-reads them.
// - Sized: the screen fills the element (the fit addon, on every resize); data-rows makes it that many rows high.
// - No trap: Tab completes a command, but on an empty line (and Shift+Tab always) it moves the focus on.
//
// Why xterm.js and not cells of our own: selection, scrollback, VT parsing, links and a screen-reader mode are the
// reference implementation's decade of work; what a page lacks is a shell, so that is what this file adds.
//
// The line editor counts one column per character: wide characters (CJK, emoji) in a typed line misplace the cursor.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js -
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// 'default' = a prompt that takes input, 'running' = a command or a replay runs, 'readonly' = output only
const terminalStates = ['default', 'running', 'readonly'];
/** setState() configs per state (getState() reports the prompt and, while running, the command). */
export interface TerminalStateConfigs {
  /** A prompt that takes input. */
  default: {
    /** the prompt text (written to data-prompt) */
    prompt?: string;
  };
  /** A command runs (setState runs it as if typed) - or, without one, the script replays. Lands in default. */
  running: {
    /** the command line to run */
    command?: string;
  };
  /** Output only: no prompt, no input (data-readonly). */
  readonly: {};
}
/** The pinned official xterm.js build the component loads (always this exact version). */
export const XTERM_URL = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.mjs';
/** Where the vendor files come from - the pinned jsDelivr builds, or self-hosted copies of the same versions. */
export interface TerminalUrls {
  /** the xterm.js ES module (exports Terminal) */
  xterm: string;
  /** xterm's stylesheet */
  css: string;
  /** the fit addon - sizes the terminal to its box */
  fit: string;
  /** the clipboard addon - OSC 52: programs reach the browser's clipboard */
  clipboard: string;
  /** the serialize addon - the buffer as VT sequences or HTML */
  serialize: string;
  /** the web-links addon - URLs in the output become links */
  webLinks: string;
}
const PINNED: TerminalUrls = {
  xterm: XTERM_URL,
  css: 'https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css',
  fit: 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.mjs',
  clipboard: 'https://cdn.jsdelivr.net/npm/@xterm/addon-clipboard@0.2.0/lib/addon-clipboard.mjs',
  serialize: 'https://cdn.jsdelivr.net/npm/@xterm/addon-serialize@0.14.0/lib/addon-serialize.mjs',
  webLinks: 'https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.mjs',
};
const urls: TerminalUrls = { ...PINNED };
/** A tone of the system - the same words as the Code Mockup's line tones. */
export type TerminalTone = 'success' | 'warning' | 'info' | 'destructive' | 'muted' | 'bold';
/** What a command handler gets: its words, the line, ways to write, and a signal that Ctrl+C aborts. */
export interface TerminalCommandContext {
  /** the command's name as typed (the first word) */
  name: string;
  /** the words after the name ("quoted words" stay one) */
  args: string[];
  /** the whole line as typed */
  line: string;
  /** the terminal element */
  terminal: HTMLElement;
  /** writes text - "\n" starts a new line */
  write: (text: string) => void;
  /** writes a line */
  writeln: (text?: string) => void;
  /** aborted by Ctrl+C, setState('default') or the element leaving the page */
  signal: AbortSignal;
}
/** A command: what it returns (text, lines, or a promise of them) is written after it. */
export type TerminalCommand = (
  context: TerminalCommandContext,
) => string | string[] | void | Promise<string | string[] | void>;
/** A command with a description - help lists it. */
export interface TerminalCommandSpec {
  /** runs the command */
  run: TerminalCommand;
  /** one line for help */
  description?: string;
  /** left out of help and Tab completion */
  hidden?: boolean;
}
/** One step of a replayed session (script.terminal-script holds an array of them). */
export interface TerminalStep {
  /** a command line, typed at the prompt and run - through `output` when it is given, else through the command */
  command?: string;
  /** text written (after the command, or alone); an array writes one line each */
  output?: string | string[];
  /** the output's tone */
  tone?: TerminalTone;
  /** ms to wait before the step */
  wait?: number;
  /** false: the command appears at once instead of being typed */
  typing?: boolean;
}
/** Fires when a command line is submitted, before it runs. */
export interface TerminalCommandDetail {
  /** the whole line */
  line: string;
  /** the command's name (the first word) */
  name: string;
  /** the words after it */
  args: string[];
}
/** Fires once the xterm is open and sized. */
export interface TerminalReadyDetail {
  /** columns */
  cols: number;
  /** rows */
  rows: number;
}
// -- the part of xterm.js this component uses ---------------------------------------------------------
/** What xterm returns for a listener - disposing it removes the listener. */
interface XtermDisposable {
  /** removes the listener */
  dispose(): void;
}
/** One line of an xterm buffer. */
interface XtermBufferLine {
  /** the line as text; trimRight leaves out the cells never written at its end */
  translateToString(trimRight?: boolean): string;
}
/** An xterm buffer: the scrollback and the screen. */
interface XtermBuffer {
  /** lines in the buffer (scrollback + screen) */
  length: number;
  /** the cursor's column */
  cursorX: number;
  /** the line at y (0 = the oldest in the scrollback) */
  getLine(y: number): XtermBufferLine | undefined;
}
/** The official xterm.js Terminal - the members this component uses (the instance has every other one too). */
export interface XtermLike {
  /** columns */
  cols: number;
  /** rows */
  rows: number;
  /** live options (theme, fonts, cursor ...) */
  options: Record<string, unknown>;
  /** the screen buffer */
  buffer: { active: XtermBuffer };
  /** opens the terminal in an element */
  open(parent: HTMLElement): void;
  /** writes data (VT sequences allowed); the callback runs once it is parsed */
  write(data: string, callback?: () => void): void;
  /** clears the scrollback and the screen but the cursor line */
  clear(): void;
  /** focuses the terminal */
  focus(): void;
  /** resizes to cols x rows */
  resize(cols: number, rows: number): void;
  /** loads an addon */
  loadAddon(addon: unknown): void;
  /** listens to typed / pasted data */
  onData(listener: (data: string) => void): XtermDisposable;
  /** decides per key event whether xterm handles it (false: the browser does) */
  attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void;
  /** scrolls to the newest line */
  scrollToBottom(): void;
  /** disposes the terminal */
  dispose(): void;
}
interface FitLike {
  fit(): void;
  proposeDimensions(): { cols: number; rows: number } | undefined;
}
interface SerializeLike {
  serialize(options?: Record<string, unknown>): string;
  serializeAsHTML(options?: Record<string, unknown>): string;
}
interface Vendor {
  Terminal: new (options: Record<string, unknown>) => XtermLike;
  FitAddon: new () => FitLike;
  ClipboardAddon: new () => unknown;
  SerializeAddon: new () => SerializeLike;
  WebLinksAddon: new () => unknown;
}
// -- loading ------------------------------------------------------------------------------------------
let vendorPromise: Promise<Vendor> | null = null;
let loadedFrom = '';
const LOAD_MS = 20_000;
/** xterm's stylesheet, linked once per URL; resolves when it applied */
const sheets = new Map<string, Promise<void>>();
function sheet(url: string): Promise<void> {
  const known = sheets.get(url);
  if (known) return known;
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = url;
  link.dataset.terminal = '';
  const pending = new Promise<void>((resolve, reject) => {
    link.onload = () => resolve();
    link.onerror = () => reject(new Error(`the xterm stylesheet could not be loaded from ${url}`));
  });
  pending.catch(() => {
    if (sheets.get(url) === pending) sheets.delete(url);
    link.remove();
  });
  sheets.set(url, pending);
  dfDollar(document.head).append(link);
  return pending;
}
/**
 * Import the official xterm.js build, its four addons and its stylesheet - once (configure({ urls }) first to
 * self-host them). A failed load can be retried (the next call loads again).
 * @returns the modules, once they and the stylesheet have loaded
 */
function load(): Promise<Vendor> {
  const key = JSON.stringify(urls);
  if (vendorPromise && key === loadedFrom) return vendorPromise;
  loadedFrom = key;
  const files = [urls.xterm, urls.fit, urls.clipboard, urls.serialize, urls.webLinks];
  // the ONE vendor import (verify: VENDOR_IMPORTS) - the official builds, never our code; a microtask later, so a
  // failing import is a rejection the terminal shows, never an exception out of init
  const modules = Promise.all(
    files.map((vendorUrl) => Promise.resolve().then(() => import(/* @vite-ignore */ vendorUrl))),
  );
  let timer = 0;
  const deadline = new Promise<never>((_, reject) => {
    timer = setTimeout(
      () => reject(new Error(`xterm.js did not load within ${LOAD_MS / 1000} s`)),
      LOAD_MS,
    ) as unknown as number;
  });
  // the stylesheet loads beside the modules, but a failing module names the failure: with Promise.all whichever
  // failed first did - VERIFIED: (section-bundles.e2e, the v0.9.14 release run) offline, one run reported the
  // stylesheet, the next the module, and the two bundles' errors differed
  const css = sheet(urls.css);
  const pending = Promise.race([modules.then((loaded) => css.then(() => loaded)), deadline])
    .then(([xterm, fit, clipboard, serialize, links]) => ({
      Terminal: xterm.Terminal,
      FitAddon: fit.FitAddon,
      ClipboardAddon: clipboard.ClipboardAddon,
      SerializeAddon: serialize.SerializeAddon,
      WebLinksAddon: links.WebLinksAddon,
    }))
    .finally(() => clearTimeout(timer));
  pending.catch(() => {
    if (vendorPromise === pending) vendorPromise = null;
  });
  vendorPromise = pending;
  return pending;
}
// -- the theme: --terminal-* custom properties as xterm's colours ------------------------------------
const COLORS: [string, string][] = [
  ['background', 'background'],
  ['foreground', 'foreground'],
  ['cursor', 'cursor'],
  ['cursorAccent', 'cursor-accent'],
  ['selectionBackground', 'selection'],
  ['black', 'black'],
  ['red', 'red'],
  ['green', 'green'],
  ['yellow', 'yellow'],
  ['blue', 'blue'],
  ['magenta', 'magenta'],
  ['cyan', 'cyan'],
  ['white', 'white'],
  ['brightBlack', 'bright-black'],
  ['brightRed', 'bright-red'],
  ['brightGreen', 'bright-green'],
  ['brightYellow', 'bright-yellow'],
  ['brightBlue', 'bright-blue'],
  ['brightMagenta', 'bright-magenta'],
  ['brightCyan', 'bright-cyan'],
  ['brightWhite', 'bright-white'],
];
let probe: CanvasRenderingContext2D | null = null;
/** any CSS colour (oklch, color-mix, a system colour) as rgba() - the browser's own conversion, one pixel */
function rgba(css: string): string {
  if (!css) return '';
  probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
  if (!probe) return '';
  probe.clearRect(0, 0, 1, 1);
  probe.fillStyle = 'rgba(1, 2, 3, 0.5)';
  probe.fillStyle = css;
  if (probe.fillStyle === 'rgba(1, 2, 3, 0.5)') return '';
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
  return `rgba(${r}, ${g}, ${b}, ${Math.round((a / 255) * 100) / 100})`;
}
/**
 * The xterm theme the element's --terminal-* custom properties describe (colours as rgba()).
 * @param el - the .terminal element
 * @returns xterm's ITheme: background, foreground, cursor, selection and the sixteen ANSI colours
 */
function themeOf(el: HTMLElement): Record<string, string> {
  const cs = getComputedStyle(el);
  const theme: Record<string, string> = {};
  for (const [key, prop] of COLORS) {
    const value = rgba(cs.getPropertyValue(`--terminal-${prop}`).trim());
    if (value) theme[key] = value;
  }
  // the scrollbar of xterm 6 follows the text, faintly
  if (theme.foreground) {
    const fg = theme.foreground.replace(/[\d.]+\)$/, '');
    theme.scrollbarSliderBackground = `${fg}0.2)`;
    theme.scrollbarSliderHoverBackground = `${fg}0.35)`;
    theme.scrollbarSliderActiveBackground = `${fg}0.5)`;
  }
  return theme;
}
const reducedMotion = (): boolean => matchMedia('(prefers-reduced-motion: reduce)').matches;
/** the options the element's attributes and computed style ask for */
function optionsOf(el: HTMLElement, host: HTMLElement): Record<string, unknown> {
  const cs = getComputedStyle(host);
  const ecs = getComputedStyle(el);
  const style = (el.dataset.cursor || ecs.getPropertyValue('--terminal-cursor-style').trim() || 'block') as string;
  return {
    theme: themeOf(el),
    fontFamily: cs.fontFamily,
    fontSize: parseFloat(cs.fontSize) || 14,
    lineHeight: parseFloat(ecs.getPropertyValue('--terminal-line-height')) || 1.2,
    cursorStyle: ['block', 'bar', 'underline'].includes(style) ? style : 'block',
    cursorBlink: !reducedMotion() && el.dataset.blink !== 'false',
    scrollback: Math.max(0, Number(el.dataset.scrollback) || 1000),
    convertEol: true,
    allowProposedApi: true,
    screenReaderMode: el.hasAttribute('data-screen-reader'),
    disableStdin: el.hasAttribute('data-readonly'),
  };
}
// -- tones and text -----------------------------------------------------------------------------------
const SGR: Record<TerminalTone, string> = {
  success: '32',
  warning: '33',
  info: '34',
  destructive: '31',
  muted: '90',
  bold: '1',
};
/**
 * Text in a tone, as the VT sequences xterm draws in the theme's colours.
 * @param text - the text
 * @param tone - success, warning, info, destructive, muted or bold
 * @returns the text wrapped in the tone's SGR sequence (unchanged for an unknown tone)
 */
function tone(text: string, tone: TerminalTone): string {
  const code = SGR[tone];
  return code ? `\x1b[${code}m${text}\x1b[0m` : text;
}
const ESC = '\x1b';
/** a CSI sequence (SGR colours, cursor moves) at any place in a text */
const CSI = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, 'g');
/** a key's sequence at the start of the input: CSI (arrows, Delete, Home / End) or SS3 */
const KEY_SEQ = new RegExp(`^${ESC}(?:\\[[0-9;]*[A-Za-z~]|O[A-Za-z])`);
/** the visible length of text with SGR sequences */
const visible = (text: string): number => text.replace(CSI, '').length;
/** the words of a line: spaces split, "double" or 'single' quotes keep a word together */
function words(line: string): string[] {
  const out: string[] = [];
  for (const m of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
  return out;
}
const resolve = (t: string | HTMLElement): HTMLElement | null =>
  typeof t === 'string'
    ? ((dfDollar('#' + CSS.escape(t)).get(0) ?? dfDollar(t).get(0) ?? null) as HTMLElement | null)
    : t;
// -- the session: one per terminal element (el._terminal) ----------------------------------------------
interface Session {
  el: HTMLElement;
  term: XtermLike;
  fit: FitLike;
  serialize: SerializeLike;
  /** the input line and the cursor in it */
  line: string;
  cursor: number;
  /** the cursor as last drawn (the input block's geometry follows from it) */
  shown: number;
  /** a prompt with the input line is on screen */
  prompted: boolean;
  history: string[];
  /** where Up / Down are in the history (history.length = the line being written) */
  at: number;
  draft: string;
  /** the running command (aborts it) and its line */
  busy: AbortController | null;
  running: string;
  /** a replay in progress, and whether a key asked to finish it */
  playing: Promise<void> | null;
  skip: boolean;
  /** typed while no prompt took it - fed once one does */
  queue: string;
  /** writes in order */
  chain: Promise<void>;
  theme: string;
  observer: ResizeObserver;
}
const live = new Set<HTMLElement>();
/** writes data and resolves once xterm has parsed it (in order with every other write) */
function put(s: Session, data: string): Promise<void> {
  s.chain = s.chain.then(() => new Promise<void>((done) => s.term.write(data, done)));
  return s.chain;
}
const promptOf = (s: Session): string => {
  const text = s.el.dataset.prompt ?? '$ ';
  const t = s.el.dataset.promptTone as TerminalTone | undefined;
  return t ? tone(text, t) : text;
};
/** the input block: the prompt and the line, the cursor placed - drawn again from its first row */
function draw(s: Session): Promise<void> {
  const cols = Math.max(1, s.term.cols);
  const prompt = promptOf(s);
  const p = visible(prompt);
  const up = Math.floor((p + s.shown) / cols);
  let out = (up ? `\x1b[${up}A` : '') + '\r\x1b[J' + prompt + s.line;
  const end = p + s.line.length;
  // a full last row leaves xterm's cursor waiting to wrap: write past it, so the cursor stands on the next row
  if (end > 0 && end % cols === 0) out += ' \b';
  const target = p + s.cursor;
  const back = Math.floor(end / cols) - Math.floor(target / cols);
  out += (back ? `\x1b[${back}A` : '') + '\r' + (target % cols ? `\x1b[${target % cols}C` : '');
  s.shown = s.cursor;
  return put(s, out);
}
/** takes the input block off the screen (output goes where it stood, the block comes back after it) */
function erase(s: Session): Promise<void> {
  const cols = Math.max(1, s.term.cols);
  const up = Math.floor((visible(promptOf(s)) + s.shown) / cols);
  s.shown = 0;
  return put(s, (up ? `\x1b[${up}A` : '') + '\r\x1b[J');
}
/** a fresh prompt on a line of its own; then whatever was typed meanwhile */
async function showPrompt(s: Session): Promise<void> {
  if (s.el.hasAttribute('data-readonly') || !s.el.isConnected) return;
  await s.chain;
  if (s.term.buffer.active.cursorX !== 0) await put(s, '\r\n');
  s.line = '';
  s.cursor = 0;
  s.shown = 0;
  s.at = s.history.length;
  s.prompted = true;
  await draw(s);
  drain(s);
}
/** feeds what was typed while no prompt took it */
function drain(s: Session): void {
  if (!s.queue) return;
  const queued = s.queue;
  s.queue = '';
  feed(s, queued);
}
/** text as the terminal shows it: an array is one line each, "\n" a new line */
const asText = (value: string | string[] | void | undefined): string =>
  value == null ? '' : Array.isArray(value) ? value.join('\n') : String(value);
/**
 * Writes output: above the prompt while one waits (the input line comes back below it), else where the cursor is.
 * @returns once it is on screen
 */
async function output(s: Session, text: string, newline = false): Promise<void> {
  const data = text.replace(/\r?\n/g, '\r\n') + (newline ? '\r\n' : '');
  if (!s.prompted) return put(s, data);
  await erase(s);
  await put(s, data);
  if (s.term.buffer.active.cursorX !== 0) await put(s, '\r\n');
  await draw(s);
}
// -- commands -----------------------------------------------------------------------------------------
const globalCommands = new Map<string, TerminalCommandSpec>();
const asSpec = (c: TerminalCommand | TerminalCommandSpec): TerminalCommandSpec =>
  typeof c === 'function' ? { run: c } : c;
/** every command the terminal knows: its own, then the global ones, then the built-ins */
function commandsOf(s: Session): Map<string, TerminalCommandSpec> {
  const all = new Map<string, TerminalCommandSpec>(BUILTINS);
  for (const [k, v] of globalCommands) all.set(k, v);
  for (const [k, v] of (s.el._terminalCommands as Map<string, TerminalCommandSpec> | undefined) ?? []) all.set(k, v);
  return all;
}
/** a command by name: as written, else in lower case (DOS commands are typed in either) */
function lookup(s: Session, name: string): TerminalCommandSpec | undefined {
  const all = commandsOf(s);
  return all.get(name) ?? all.get(name.toLowerCase());
}
const BUILTINS = new Map<string, TerminalCommandSpec>([
  [
    'help',
    {
      description: 'lists the commands (help <name>: one of them)',
      run: (c) => {
        const s = c.terminal._terminal as Session;
        const all = [...commandsOf(s)].filter(([, v]) => !v.hidden).sort(([a], [b]) => a.localeCompare(b));
        if (c.args[0]) {
          const one = lookup(s, c.args[0]);
          return one ? `${c.args[0]} - ${one.description || 'no description'}` : `help: no command ${c.args[0]}`;
        }
        const width = Math.max(...all.map(([k]) => k.length));
        return all.map(([k, v]) => `${tone(k.padEnd(width), 'bold')}  ${v.description ?? ''}`.trimEnd());
      },
    },
  ],
  [
    'clear',
    {
      description: 'clears the screen',
      run: (c) => {
        const s = c.terminal._terminal as Session;
        return put(s, '\x1b[H\x1b[2J').then(() => s.term.clear());
      },
    },
  ],
  ['echo', { description: 'writes its words', run: (c) => c.args.join(' ') }],
  [
    'history',
    {
      description: 'lists the commands typed so far',
      run: (c) => {
        const s = c.terminal._terminal as Session;
        return s.history.map((h, i) => `${String(i + 1).padStart(4)}  ${h}`);
      },
    },
  ],
]);
/** the state the terminal is in now, recorded (it moved there by itself) */
const settle = (s: Session): void => {
  const name = s.el.hasAttribute('data-readonly') ? 'readonly' : s.busy || s.playing ? 'running' : 'default';
  dfDollar(s.el).attr('aria-busy', name === 'running' ? 'true' : null);
  const config = name === 'running' && s.running ? { command: s.running } : { prompt: s.el.dataset.prompt ?? '$ ' };
  if (s.el.store) terminalApi.commit(s.el, name, config);
};
/** runs a submitted line: the event, the command (Ctrl+C aborts it), its output - then a prompt */
async function execute(s: Session, line: string, fixed?: { output?: string | string[]; tone?: TerminalTone }) {
  const [name = '', ...args] = words(line);
  const el = s.el;
  if (line.trim()) {
    // Fires when a command line is submitted, before it runs - preventDefault() keeps the terminal from running
    // it (the page answers through write()).
    const go = el.dispatchEvent(
      new CustomEvent<TerminalCommandDetail>('terminal-command', {
        bubbles: true,
        cancelable: true,
        detail: { line, name, args },
      }),
    );
    if (go || fixed) {
      const ac = new AbortController();
      s.busy = ac;
      s.running = line;
      settle(s);
      const ctx: TerminalCommandContext = {
        name,
        args,
        line,
        terminal: el,
        write: (text) => void put(s, String(text).replace(/\r?\n/g, '\r\n')),
        writeln: (text = '') => void put(s, String(text).replace(/\r?\n/g, '\r\n') + '\r\n'),
        signal: ac.signal,
      };
      try {
        let result: string | string[] | void;
        if (fixed && 'output' in fixed) {
          const text = asText(fixed.output);
          result = fixed.tone ? text.split('\n').map((l) => tone(l, fixed.tone as TerminalTone)) : text;
        } else {
          const spec = lookup(s, name);
          if (!spec) result = tone(notFound(el, name), 'destructive');
          else {
            const aborted = new Promise<void>((done) =>
              ac.signal.addEventListener('abort', () => done(), { once: true }),
            );
            result = await Promise.race([Promise.resolve(spec.run(ctx)), aborted]);
          }
        }
        if (!ac.signal.aborted) {
          let text = asText(result);
          // a replayed step without output of its own: the command answers, in the step's tone
          if (text && fixed?.tone && !('output' in fixed))
            text = text
              .split('\n')
              .map((l) => tone(l, fixed.tone as TerminalTone))
              .join('\n');
          if (text) await put(s, text.replace(/\r?\n/g, '\r\n') + '\r\n');
        }
      } catch (err) {
        await put(s, tone(`${name}: ${err instanceof Error ? err.message : String(err)}`, 'destructive') + '\r\n');
      } finally {
        if (s.busy === ac) {
          s.busy = null;
          s.running = '';
        }
      }
    }
  }
  if (!s.playing) {
    await showPrompt(s);
    settle(s);
  }
}
const notFound = (el: HTMLElement, name: string): string =>
  (el.dataset.notFound ?? '{name}: command not found').replace('{name}', name);
/** Tab: completes the command's name - one match is written out, several are listed */
async function complete(s: Session): Promise<void> {
  const before = s.line.slice(0, s.cursor);
  if (/\s/.test(before)) return;
  const names = [...commandsOf(s)].filter(([k, v]) => !v.hidden && k.startsWith(before)).map(([k]) => k);
  if (!names.length) return;
  if (names.length === 1) return insert(s, names[0].slice(before.length) + ' ');
  let common = names[0];
  for (const n of names) while (!n.startsWith(common)) common = common.slice(0, -1);
  if (common.length > before.length) return insert(s, common.slice(before.length));
  await output(s, names.sort().join('  '));
}
function insert(s: Session, text: string): Promise<void> {
  s.line = s.line.slice(0, s.cursor) + text + s.line.slice(s.cursor);
  s.cursor += text.length;
  return draw(s);
}
/** keys and typed text, in order; a submitted line waits for its command, the rest after it */
function feed(s: Session, data: string): void {
  for (let i = 0; i < data.length;) {
    if (!s.prompted) {
      s.queue += data.slice(i);
      return;
    }
    const rest = data.slice(i);
    const seq = KEY_SEQ.exec(rest)?.[0];
    const key = seq ?? rest[0];
    i += key.length;
    switch (key) {
      case '\r':
      case '\n': {
        const line = s.line;
        s.cursor = s.line.length;
        void draw(s);
        void put(s, '\r\n');
        s.prompted = false;
        if (line.trim() && line !== s.history[s.history.length - 1]) s.history.push(line);
        void execute(s, line);
        if (data[i - 1] === '\r' && data[i] === '\n') i++;
        continue;
      }
      case '\x7f':
      case '\b':
        if (s.cursor > 0) {
          s.line = s.line.slice(0, s.cursor - 1) + s.line.slice(s.cursor);
          s.cursor--;
          void draw(s);
        }
        continue;
      case '\x1b[3~':
        if (s.cursor < s.line.length) {
          s.line = s.line.slice(0, s.cursor) + s.line.slice(s.cursor + 1);
          void draw(s);
        }
        continue;
      case '\x1b[D':
        if (s.cursor > 0) {
          s.cursor--;
          void draw(s);
        }
        continue;
      case '\x1b[C':
        if (s.cursor < s.line.length) {
          s.cursor++;
          void draw(s);
        }
        continue;
      case '\x1b[H':
      case '\x1bOH':
      case '\x01':
        s.cursor = 0;
        void draw(s);
        continue;
      case '\x1b[F':
      case '\x1bOF':
      case '\x05':
        s.cursor = s.line.length;
        void draw(s);
        continue;
      case '\x1b[A':
      case '\x1b[B': {
        if (!s.history.length) continue;
        if (s.at === s.history.length) s.draft = s.line;
        s.at = Math.max(0, Math.min(s.history.length, s.at + (key === '\x1b[A' ? -1 : 1)));
        s.line = s.at === s.history.length ? s.draft : s.history[s.at];
        s.cursor = s.line.length;
        void draw(s);
        continue;
      }
      case '\x15':
        s.line = s.line.slice(s.cursor);
        s.cursor = 0;
        void draw(s);
        continue;
      case '\x03':
        s.cursor = s.line.length;
        void draw(s);
        void put(s, '^C');
        s.prompted = false;
        void showPrompt(s);
        continue;
      case '\x0c':
        s.prompted = false;
        // the line stays; what follows Ctrl+L in the same input waits for the redraw
        void put(s, '\x1b[H\x1b[2J')
          .then(() => {
            s.term.clear();
            s.prompted = true;
            s.shown = 0;
            return draw(s);
          })
          .then(() => drain(s));
        continue;
      case '\t':
        void complete(s);
        continue;
      default:
        if (key.length === 1 && key >= ' ' && key !== '\x7f') {
          // a run of plain characters goes in at once (a paste)
          let end = 0;
          while (end < rest.length && rest.charCodeAt(end) >= 32 && rest.charCodeAt(end) !== 127) end++;
          const run = rest.slice(0, end) || key;
          i += run.length - 1;
          void insert(s, run);
        }
    }
  }
}
/** what a key does while no prompt waits: Ctrl+C aborts the command; any key finishes a replay */
function onData(s: Session, data: string): void {
  if (s.el.hasAttribute('data-readonly')) return;
  if (s.playing) {
    s.skip = true;
    return;
  }
  if (s.busy) {
    if (data.includes('\x03')) {
      s.busy.abort();
      s.queue = '';
      void put(s, '^C\r\n');
    } else s.queue += data;
    return;
  }
  feed(s, data);
}
// -- replay ---------------------------------------------------------------------------------------------
const sleep = (s: Session, ms: number): Promise<void> =>
  s.skip || ms <= 0 ? Promise.resolve() : new Promise((done) => setTimeout(done, ms));
/** the session of the element's script.terminal-script (an empty list without one, or with invalid JSON) */
function scriptOf(el: HTMLElement): TerminalStep[] {
  const script = dfDollar(el).children('script.terminal-script').get(0);
  if (!script) return [];
  try {
    const steps = JSON.parse(script.textContent || '[]');
    return Array.isArray(steps) ? steps : [];
  } catch (err) {
    console.error('[terminal] the script is not valid JSON:', err instanceof Error ? err.message : String(err));
    return [];
  }
}
/** a command typed at the prompt (instantly when skipping, without typing or with reduced motion) */
async function typeCommand(s: Session, text: string, typing: boolean): Promise<void> {
  const delay = Math.max(0, Number(s.el.dataset.speed ?? 45));
  if (s.el.hasAttribute('data-readonly')) {
    await put(s, (s.term.buffer.active.cursorX ? '\r\n' : '') + promptOf(s));
    for (let i = 0; i < text.length; i++) {
      await put(s, text[i]);
      if (typing && !s.skip && !reducedMotion()) await sleep(s, delay * (0.6 + ((i * 7) % 5) / 10));
    }
    return put(s, '\r\n');
  }
  if (!s.prompted) await showPrompt(s);
  if (s.line) {
    s.line = '';
    s.cursor = 0;
    await draw(s);
  }
  for (let i = 0; i < text.length; i++) {
    if (s.skip || !typing || reducedMotion()) {
      await insert(s, text.slice(i));
      break;
    }
    await insert(s, text[i]);
    await sleep(s, delay * (0.6 + ((i * 7) % 5) / 10));
  }
  s.cursor = s.line.length;
  await draw(s);
  await put(s, '\r\n');
  s.prompted = false;
}
async function play(s: Session, steps: TerminalStep[]): Promise<void> {
  if (s.playing) return s.playing;
  s.playing = (async () => {
    settle(s);
    for (const step of steps) {
      if (!s.el.isConnected) break;
      await sleep(s, Number(step.wait) || 0);
      if (typeof step.command === 'string') {
        await typeCommand(s, step.command, step.typing !== false);
        if (step.command.trim() && step.command !== s.history[s.history.length - 1]) s.history.push(step.command);
        await execute(
          s,
          step.command,
          'output' in step ? { output: step.output, tone: step.tone } : step.tone ? { tone: step.tone } : undefined,
        );
      } else if (step.output != null) {
        const text = asText(step.output);
        await output(
          s,
          step.tone
            ? text
                .split('\n')
                .map((l) => tone(l, step.tone as TerminalTone))
                .join('\n')
            : text,
          true,
        );
      }
    }
  })().finally(() => {
    s.playing = null;
    s.skip = false;
    void showPrompt(s).then(() => settle(s));
  });
  return s.playing;
}
// -- opening ---------------------------------------------------------------------------------------------
async function open(el: HTMLElement): Promise<Session> {
  const host = document.createElement('div');
  host.className = 'terminal-screen';
  host.setAttribute('data-ce-chrome', '');
  dfDollar(el).append(host);
  let vendor: Vendor;
  try {
    vendor = await load();
  } catch (err) {
    dfDollar(el).attr('data-error', '');
    const out = document.createElement('output');
    out.className = 'terminal-error';
    out.setAttribute('role', 'alert');
    dfDollar(out).text(`The terminal could not start: ${err instanceof Error ? err.message : String(err)}`);
    dfDollar(host).append(out);
    throw err;
  }
  await document.fonts?.ready;
  await sized(host);
  const term = new vendor.Terminal(optionsOf(el, host));
  const fit = new vendor.FitAddon();
  const serialize = new vendor.SerializeAddon();
  term.loadAddon(fit);
  term.loadAddon(serialize);
  term.loadAddon(new vendor.ClipboardAddon());
  term.loadAddon(new vendor.WebLinksAddon());
  term.open(host);
  const s: Session = {
    el,
    term,
    fit,
    serialize,
    line: '',
    cursor: 0,
    shown: 0,
    prompted: false,
    history: [],
    at: 0,
    draft: '',
    busy: null,
    running: '',
    playing: null,
    skip: false,
    queue: '',
    chain: Promise.resolve(),
    theme: '',
    observer: new ResizeObserver(() => resize(s)),
  };
  s.theme = JSON.stringify(term.options.theme);
  el._terminal = s;
  live.add(el);
  resize(s, true);
  s.observer.observe(host);
  term.onData((data) => onData(s, data));
  // Tab completes - but on an empty line, while nothing takes input, and with Shift it moves the focus on (no trap)
  term.attachCustomKeyEventHandler(
    (e) => !(e.key === 'Tab' && (e.shiftKey || !s.prompted || !s.line || el.hasAttribute('data-readonly'))),
  );
  const label = el.getAttribute('aria-label');
  const textarea = dfDollar(host).find('textarea').get(0) as HTMLTextAreaElement | undefined;
  if (textarea && label) textarea.setAttribute('aria-label', label);
  // Fires once the xterm is open and sized - its columns and rows.
  el.dispatchEvent(
    new CustomEvent<TerminalReadyDetail>('terminal-ready', {
      bubbles: true,
      detail: { cols: term.cols, rows: term.rows },
    }),
  );
  dfDollar(el).attr('data-ready', '');
  if (el.hasAttribute('data-autoplay') && scriptOf(el).length) autoplay(el, s);
  else await showPrompt(s);
  settle(s);
  return s;
}
/** resolves once the element has a width (at once when it has one) */
function sized(host: HTMLElement): Promise<void> {
  if (host.clientWidth > 0) return Promise.resolve();
  return new Promise((done) => {
    const ro = new ResizeObserver(() => {
      if (host.clientWidth <= 0) return;
      ro.disconnect();
      done();
    });
    ro.observe(host);
  });
}
/** data-autoplay: the script plays when the terminal first shows (after data-autoplay ms) - no prompt until then,
 *  keys typed meanwhile wait in the queue */
function autoplay(el: HTMLElement, s: Session): void {
  const delay = Math.max(0, Number(el.getAttribute('data-autoplay')) || 0);
  const io = new IntersectionObserver(
    (entries) => {
      if (!entries.some((e) => e.isIntersecting)) return;
      io.disconnect();
      s.playing = new Promise((done) => setTimeout(done, delay));
      void s.playing.then(() => {
        s.playing = null;
        return play(s, scriptOf(el));
      });
    },
    { threshold: 0.35 },
  );
  io.observe(el);
}
/** the screen fits its box (data-rows: that many rows, as wide as the box) - the input line drawn again */
function resize(s: Session, first = false): void {
  if (!s.el.isConnected) return;
  const dims = s.fit.proposeDimensions();
  if (!dims || !Number.isFinite(dims.cols) || dims.cols < 2) return;
  const rows = Number(s.el.dataset.rows) || dims.rows;
  if (dims.cols === s.term.cols && rows === s.term.rows && !first) return;
  s.term.resize(dims.cols, Math.max(1, rows));
  if (s.prompted) void draw(s);
}
/** a session once the terminal is open (the element initialized first) */
function session(target: string | HTMLElement): Promise<Session> {
  const el = resolve(target);
  if (!el) return Promise.reject(new Error('no terminal'));
  init();
  return (el._terminalReady as Promise<Session> | undefined) ?? Promise.reject(new Error('not a .terminal'));
}
// -- the State API -----------------------------------------------------------------------------------
/** The markup of a state: data-readonly in readonly, aria-busy while running, the prompt as data-prompt. */
function applyMarkup(el: HTMLElement, name: string, config: Record<string, unknown> = {}): void {
  dfDollar(el).attr('data-readonly', name === 'readonly' ? '' : null);
  dfDollar(el).attr('aria-busy', name === 'running' ? 'true' : null);
  // only a prompt other than the one in effect is written (no data-prompt means "$ ")
  if (name === 'default' && typeof config.prompt === 'string' && config.prompt !== (el.dataset.prompt ?? '$ '))
    dfDollar(el).attr('data-prompt', config.prompt);
}
async function triggerStateChange(el: HTMLElement, name: string, config: Record<string, unknown> = {}) {
  if (!el.hasAttribute('data-ready')) {
    // not open yet (a closed window, a hidden tab, the vendor still loading): the markup now - open() reads it -
    // and a command once the terminal has opened; the state change never waits for a box to show
    if (name !== 'running') applyMarkup(el, name, config);
    else
      void (el._terminalReady as Promise<Session>).then(
        () => triggerStateChange(el, name, config),
        () => {},
      );
    return;
  }
  const s = await (el._terminalReady as Promise<Session>);
  if (name === 'running') {
    const command = typeof config.command === 'string' ? config.command : '';
    if (s.busy && s.running === command) return;
    if (!command) return play(s, scriptOf(el));
    if (el.hasAttribute('data-readonly')) applyMarkup(el, 'default', {});
    s.term.options.disableStdin = false;
    await typeCommand(s, command, false);
    s.history.push(command);
    return execute(s, command);
  }
  s.busy?.abort();
  if (s.playing) s.skip = true;
  const was = el.hasAttribute('data-readonly');
  const prompt = el.dataset.prompt;
  applyMarkup(el, name, config);
  s.term.options.disableStdin = name === 'readonly';
  if (name === 'readonly') {
    if (s.prompted) await erase(s);
    s.prompted = false;
  } else if (was || !s.prompted) {
    s.prompted = false;
    if (!s.playing) await showPrompt(s);
  } else if (prompt !== el.dataset.prompt) await draw(s);
  settle(s);
}
/** Registry-level API; pass the element explicitly. Unknown names throw. */
export const terminalApi = componentState({
  component: 'terminal',
  states: terminalStates,
  apply: (el, state) => triggerStateChange(el as HTMLElement, state.name, state.config),
  // the state the terminal is in - a command typed, a replay playing, a prompt set by write
  read: (el) => {
    const s = (el as HTMLElement)._terminal as Session | undefined;
    const name = el.hasAttribute('data-readonly') ? 'readonly' : s?.busy || s?.playing ? 'running' : 'default';
    const prompt = (el as HTMLElement).dataset.prompt ?? '$ ';
    return { name, config: name === 'running' && s?.running ? { command: s.running } : { prompt } };
  },
  markup: (el, state) => applyMarkup(el as HTMLElement, state.name, state.config),
});
df$.terminalApi = terminalApi;
df$.terminalStates = terminalStates;
// -- df$.shadcn.terminal: the imperative surface ---------------------------------------------------
df$.terminal = {
  /**
   * Imports the official xterm.js build, its addons and its stylesheet (the first terminal does it by itself).
   * @returns once they have loaded
   */
  load: (): Promise<void> => load().then(() => undefined),
  /**
   * Where the vendor files come from - call it before the first terminal opens to self-host them.
   * @param options - the URLs to use instead of the pinned jsDelivr ones (any subset)
   */
  configure: (options: { urls?: Partial<TerminalUrls> }): void => {
    Object.assign(urls, options?.urls ?? {});
  },
  /**
   * Writes text: above the prompt while one waits (the line being typed stays), else where the cursor is.
   * @param target - the .terminal element, its id or a selector
   * @param text - the text ("\n" starts a new line; VT sequences and tone() work)
   * @returns once it is on screen
   */
  write: (target: string | HTMLElement, text: string): Promise<void> =>
    session(target).then((s) => output(s, String(text))),
  /**
   * Writes a line - write() with a new line after it.
   * @param target - the .terminal element, its id or a selector
   * @param text - the line
   * @returns once it is on screen
   */
  writeln: (target: string | HTMLElement, text: string = ''): Promise<void> =>
    session(target).then((s) => output(s, String(text), true)),
  /**
   * Clears the screen and the scrollback; a waiting prompt is drawn again at the top.
   * @param target - the .terminal element, its id or a selector
   * @returns once it is clear
   */
  clear: (target: string | HTMLElement): Promise<void> =>
    session(target).then(async (s) => {
      await put(s, '\x1b[H\x1b[2J');
      s.term.clear();
      if (s.prompted) {
        s.shown = 0;
        await draw(s);
      }
    }),
  /**
   * Runs a command line as if it were typed at the prompt (the State API's running state does the same).
   * @param target - the .terminal element, its id or a selector
   * @param line - the command line
   * @returns once the command has run and a prompt waits again
   */
  run: (target: string | HTMLElement, line: string): Promise<void> => {
    const el = resolve(target);
    if (!el?.api) return Promise.reject(new Error('no terminal'));
    el.api.setState('running', { command: String(line) });
    return el.api.settled();
  },
  /**
   * Replays a session: commands typed at the prompt and their output (a key finishes it at once).
   * @param target - the .terminal element, its id or a selector
   * @param steps - the steps (default: the element's script.terminal-script)
   * @returns once the session has played
   */
  play: (target: string | HTMLElement, steps?: TerminalStep[]): Promise<void> =>
    session(target).then((s) => play(s, steps ?? scriptOf(s.el))),
  /**
   * Adds commands - to one terminal, or to every terminal on the page with 'global'. A name the terminal knows
   * already (a built-in too) takes the new command.
   * @param target - 'global', or the .terminal element, its id or a selector
   * @param commands - each name with its handler, or with { run, description, hidden }
   * @returns a function that removes them again
   */
  commands: (
    target: 'global' | string | HTMLElement,
    commands: Record<string, TerminalCommand | TerminalCommandSpec>,
  ): (() => void) => {
    const el = target === 'global' ? null : resolve(target);
    if (target !== 'global' && !el) return () => {};
    const map: Map<string, TerminalCommandSpec> = el
      ? (el._terminalCommands ??= new Map<string, TerminalCommandSpec>())
      : globalCommands;
    const added = Object.entries(commands ?? {}).map(([k, v]): [string, TerminalCommandSpec] => [k, asSpec(v)]);
    for (const [k, v] of added) map.set(k, v);
    return () => {
      for (const [k, v] of added) if (map.get(k) === v) map.delete(k);
    };
  },
  /**
   * Sets the prompt (data-prompt) - a waiting prompt changes at once.
   * @param target - the .terminal element, its id or a selector
   * @param text - the prompt text
   */
  prompt: (target: string | HTMLElement, text: string): void => {
    const el = resolve(target);
    if (el?.api && el.api.getState().name === 'default') el.api.setState('default', { prompt: String(text) });
    else if (el) dfDollar(el).attr('data-prompt', String(text));
  },
  /**
   * The screen and the scrollback as plain text (lines trimmed at the end, trailing empty lines left out).
   * @param target - the .terminal element, its id or a selector
   * @returns the text ('' before the terminal has opened)
   */
  text: (target: string | HTMLElement): string => {
    const s = resolve(target)?._terminal as Session | undefined;
    if (!s) return '';
    const buf = s.term.buffer.active;
    const lines: string[] = [];
    // VERIFIED: translateToString(true) trims only cells never written - a written space (after a prompt) stayed
    // ("$ " in tmp/probe-term.ts), so each line is trimmed here
    for (let y = 0; y < buf.length; y++) lines.push((buf.getLine(y)?.translateToString(true) ?? '').trimEnd());
    while (lines.length && !lines[lines.length - 1]) lines.pop();
    return lines.join('\n');
  },
  /**
   * The screen and the scrollback serialized by the serialize addon - as HTML (colours inline) or as VT sequences.
   * @param target - the .terminal element, its id or a selector
   * @param format - 'html' (default) or 'vt'
   * @returns the serialized buffer ('' before the terminal has opened)
   */
  serialize: (target: string | HTMLElement, format: 'html' | 'vt' = 'html'): string => {
    const s = resolve(target)?._terminal as Session | undefined;
    if (!s) return '';
    return format === 'vt' ? s.serialize.serialize() : s.serialize.serializeAsHTML({ includeGlobalBackground: true });
  },
  /**
   * Focuses the terminal's input.
   * @param target - the .terminal element, its id or a selector
   */
  focus: (target: string | HTMLElement): void => {
    void session(target).then((s) => s.term.focus());
  },
  tone,
  /**
   * The xterm.js Terminal behind the element - for what this API does not cover.
   * @param target - the .terminal element, its id or a selector
   * @returns the instance, once the terminal has opened
   */
  xterm: (target: string | HTMLElement): Promise<XtermLike> => session(target).then((s) => s.term),
  /**
   * The xterm theme the element's --terminal-* custom properties describe.
   * @param target - the .terminal element, its id or a selector
   * @returns xterm's theme (colours as rgba()), {} for no terminal
   */
  theme: (target: string | HTMLElement): Record<string, string> => {
    const el = resolve(target);
    return el ? themeOf(el) : {};
  },
  url: XTERM_URL,
};
// -- live re-theming ---------------------------------------------------------------------------------
let themeWatched = false;
function watchTheme(): void {
  if (themeWatched) return;
  themeWatched = true;
  let timer = 0;
  const schedule = (): void => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      for (const el of live) {
        const s = el._terminal as Session | undefined;
        if (!s) continue;
        const theme = themeOf(el);
        const json = JSON.stringify(theme);
        if (json === s.theme) continue;
        s.theme = json;
        s.term.options.theme = theme;
      }
    }, 60) as unknown as number;
  };
  const mo = new MutationObserver(schedule);
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
  if (document.head) mo.observe(document.head, { childList: true, subtree: true, characterData: true });
  for (const q of ['(prefers-color-scheme: dark)', '(prefers-contrast: more)', '(forced-colors: active)'])
    matchMedia(q).addEventListener('change', schedule);
  document.addEventListener('defuss-theme-change', schedule);
}
// -- init ----------------------------------------------------------------------------------------------
/** terminals that left the page: their xterm, observer and running command go too */
function sweep(): void {
  for (const el of live) {
    if (el.isConnected) continue;
    const s = el._terminal as Session;
    live.delete(el);
    s.busy?.abort();
    s.observer.disconnect();
    s.term.dispose();
  }
}
function init() {
  sweep();
  // VERIFIED: xterm.js 6 adds the classes "terminal" and "xterm" to the element it draws in (lib/xterm.mjs) - it is
  // no terminal of ours (terminal.e2e: no .xterm carries data-init)
  (dfDollar('.terminal:not([data-init]):not(.xterm)').toArray() as HTMLElement[]).forEach((el) => {
    el.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(el, terminalApi, {
      name: el.hasAttribute('data-readonly') ? 'readonly' : 'default',
      config: el.hasAttribute('data-readonly') ? {} : { prompt: el.dataset.prompt ?? '$ ' },
    });
    watchTheme();
    const ready = open(el);
    // the message only: a stack names the file's URL, which differs between copies of the same build
    ready.catch((err) => console.error('[terminal]', err instanceof Error ? err.message : String(err)));
    el._terminalReady = ready;
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub