Xterm.js Unification: Terminal as Provider
Status: Design Proposal
Problem
renderToXterm() is a parallel render path that duplicates core runtime logic:
- Own reconciler management — creates container, fiber root, schedules renders via
requestAnimationFrame - Own render pipeline — uses
executeRenderAdapter(adapter-aware path withrenderPhaseAdapter) instead ofexecuteRender(terminal-optimized with incrementalrenderPhase) - No runtime features — no
useInput, no focus management, noRuntimeContext, no event loop, no store - Input handled externally — showcases wire their own key/mouse handlers via callbacks instead of using the standard
useInput/useMousehooks
This means:
- Web showcases can't use
useInput,useFocusManager, or any runtime hook - The adapter render phase (
renderPhaseAdapter) lacks incremental rendering (full re-render every frame) - Two render phases must be maintained in parallel
- Bug fixes to the main pipeline don't benefit xterm.js renders
Proposed Solution: createXtermProvider()
The terminal is already modeled as a Provider (Provider<TermState, TermEvents>) via createTermProvider(stdin, stdout). Create an equivalent for xterm.js:
import type { TermState, TermEvents } from "@silvery/ag-term/runtime"
import type { Provider, ProviderEvent } from "@silvery/ag-term/runtime"
/**
* Create a terminal provider from an xterm.js Terminal instance.
*
* Returns a Provider<TermState, TermEvents> — same shape as
* createTermProvider(stdin, stdout), so run() handles it identically.
*/
export function createXtermProvider(
terminal: XtermTerminal,
options?: { cols?: number; rows?: number },
): Provider<TermState, TermEvents> & DisposableHow It Works
The xterm provider wraps terminal.onData and terminal.textarea focus events into the same ProviderEvent<TermEvents> stream that createTermProvider produces:
Node.js (createTermProvider) | xterm.js (createXtermProvider) |
|---|---|
stdin.on("data", chunk) | terminal.onData(data) |
stdout.on("resize", handler) | External resize() call or ResizeObserver |
stdout.write(frame) | terminal.write(frame) |
stdin.setRawMode(true) | N/A (xterm.js is always "raw") |
| Focus reporting via ANSI sequences | terminal.textarea focus/blur events |
State & Events
// State (same as TermState)
{ cols: number, rows: number }
// Events (same as TermEvents)
{ type: "key", data: { input: string, key: Key } }
{ type: "mouse", data: ParsedMouse }
{ type: "paste", data: { text: string } }
{ type: "resize", data: Dims }
{ type: "focus", data: { focused: boolean } }RenderTarget for xterm.js
createApp uses RenderTarget for output. For xterm.js:
function createXtermRenderTarget(terminal: XtermTerminal, provider: Provider<TermState, TermEvents>): RenderTarget {
return {
write(frame: string): void {
terminal.write(frame)
},
getDims(): Dims {
return provider.getState()
},
onResize(handler: (dims: Dims) => void): () => void {
return provider.subscribe((state) => handler(state))
},
}
}Usage After Unification
// Before: renderToXterm (special path, no runtime)
import { renderToXterm } from "@silvery/ag-term/xterm"
const instance = renderToXterm(<App />, xtermTerminal, {
input: { onKey: ..., onMouse: ..., onFocus: ... },
})
// After: run() with xterm provider (full runtime)
import { run } from "@silvery/ag-term/runtime"
import { createXtermProvider } from "@silvery/ag-term/xterm"
const term = createXtermProvider(xtermTerminal)
const handle = await run(<App />, {
term, // Provider<TermState, TermEvents>
stdout: ..., // XtermWriteStream adapter
// All runtime features work: useInput, useFocusManager, etc.
})The stdout Adapter Challenge
createApp expects stdout: NodeJS.WriteStream for:
- Terminal output —
stdout.write(frame)for ANSI data - Resize detection —
stdout.on("resize", handler),stdout.columns,stdout.rows - Protocol sequences — alternate screen, cursor hide/show, Kitty keyboard, mouse tracking, bracketed paste
For xterm.js, we need a lightweight adapter:
function createXtermWriteStream(terminal: XtermTerminal): NodeJS.WriteStream {
// Minimal WriteStream interface that delegates to terminal.write()
// and provides cols/rows from the terminal
}Alternatively, refactor initApp to accept RenderTarget directly (avoids the WriteStream adapter entirely). This is the cleaner path but requires more changes to create-app.tsx.
Implementation Plan
Phase 1: createXtermProvider() (new file)
Create packages/ag-term/src/xterm/xterm-provider.ts:
- Implements
Provider<TermState, TermEvents> - Wraps
terminal.onData→ parses keys/mouse via existingparseKey/parseMouseSequence - Wraps
terminal.textareafocus/blur → focus events getState()returns{ cols, rows }from terminalevents()yields parsed eventsresize(cols, rows)method to update state from external resize
Phase 2: Wire into run() / createApp()
Two approaches (pick one):
Option A: RenderTarget refactor (cleaner, more work)
- Add
target?: RenderTargettoAppRunOptions - When
targetis provided, skip stdout-based setup (no alternate screen, no raw mode) - Wire
target.writefor output,target.onResizefor resize - Provider events drive the event loop as usual
Option B: WriteStream adapter (less invasive, more hacky)
- Create
XtermWriteStreamthat wrapsterminal.write()and emits resize events - Pass it as
stdouttoinitApp - Skip Node.js-specific terminal setup when detecting xterm context
Phase 3: Migrate showcases
Update showcase-app.tsx to use the new API:
const provider = createXtermProvider(term)
const handle = await run(<ShowcaseComponent />, { term: provider })
// Resize
window.addEventListener("resize", () => {
fitAddon.fit()
provider.resize(term.cols, term.rows)
})Phase 4: Deprecate renderToXterm()
- Mark
renderToXtermas@deprecated - Keep it working for backwards compatibility
- Eventually remove when all consumers have migrated
Key Decisions
Render Phase: Adapter vs Terminal
Currently xterm.js uses renderPhaseAdapter (adapter-aware, no incremental rendering). After unification, it would use the main renderPhase (terminal-optimized, incremental).
This is correct because:
- xterm.js is a terminal emulator — it accepts ANSI output
- The terminal adapter (
terminalAdapter) produces ANSI diff strings - Incremental rendering benefits xterm.js too (less data per frame = smoother updates)
Browser Compatibility
createApp imports Node.js modules (node:process, etc.). For browser:
- Conditional imports or tree-shaking for Node-specific features
- Skip: alternate screen, raw mode, SIGTSTP/SIGCONT handling, Kitty keyboard protocol
- Keep: Provider event loop, focus management, store, render pipeline
What About Canvas/DOM Adapters?
Canvas and DOM renders are fundamentally different (pixels/DOM nodes vs character cells). Those remain as separate render paths. The xterm.js unification only applies because xterm.js IS a terminal — it speaks ANSI.
Alternatives Considered
1. TerminalIO Abstraction
Create a new TerminalIO interface ({ write, cols, rows, onInput, onResize }). Rejected because:
- Duplicates the existing Provider pattern
- Adds a new abstraction when one already exists
- Provider has state management built in (getState/subscribe)
2. Extend Term Interface for xterm.js
Create createTerm(xtermTerminal) overload. Rejected because:
- Term includes styling (chalk proxy), detection, stream access — none of which apply to xterm.js
- Term is a Node.js concept; Provider is the portable abstraction
3. Keep renderToXterm as-is
Leave it as a separate lightweight path. Rejected because:
- Showcases can't demonstrate runtime features (useInput, focus management)
- Two parallel render phases is a maintenance burden
- No incremental rendering in browser = worse performance