react-xterm-shell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noah Wardlow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # react-xterm-shell
2
+
3
+ > **Beta** — under active development; the API may change between minor versions until 1.0.
4
+
5
+ A small React shell around [xterm.js](https://xtermjs.org/). It is a **React shell around xterm, not a React renderer for terminal cells** — xterm owns the grid, parsing, and rendering; this gives you the lifecycle, a stable imperative controller, automatic fitting, and opt-in addons as ordinary React.
6
+
7
+ - `useXTerm()` — creates and owns the xterm instance behind a stable controller.
8
+ - `<XTerm />` — the DOM mount point.
9
+ - `TerminalProvider` / `useTerminalController()` — drive the terminal from chrome (toolbars) without prop drilling.
10
+ - Automatic sizing via `FitAddon` + `ResizeObserver`.
11
+ - Opt-in `webgl` (with DOM fallback), `web-links`, and `unicode11` addons, on by default.
12
+
13
+ It does **not** include a transport — wire `onData`/`onResize` to your own WebSocket/PTY backend. That keeps the wrapper reusable across rosbridge, a PTY service, SSH, or a local shell.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install react-xterm-shell @xterm/xterm react@^19
19
+ ```
20
+
21
+ React 19 and `@xterm/xterm` are peer dependencies. Import the xterm stylesheet once in your app:
22
+
23
+ ```ts
24
+ import "@xterm/xterm/css/xterm.css";
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```tsx
30
+ import { useXTerm, XTerm } from "react-xterm-shell";
31
+ import "@xterm/xterm/css/xterm.css";
32
+
33
+ function TerminalPanel({ socket }: { socket: WebSocket }) {
34
+ const terminal = useXTerm({
35
+ onData: (data) => socket.send(JSON.stringify({ type: "input", data })),
36
+ onResize: ({ cols, rows }) =>
37
+ socket.send(JSON.stringify({ type: "resize", cols, rows })),
38
+ theme: { background: "#1a1b26", foreground: "#a9b1d6" }
39
+ });
40
+
41
+ // Stream server output into the terminal:
42
+ socket.onmessage = (e) => terminal.write(e.data);
43
+
44
+ return <XTerm terminal={terminal} style={{ width: "100%", height: 420 }} />;
45
+ }
46
+ ```
47
+
48
+ ## Composition
49
+
50
+ `useXTerm` returns a stable controller, so a toolbar can drive it imperatively
51
+ without re-rendering as bytes stream:
52
+
53
+ ```tsx
54
+ import { useXTerm, XTerm, TerminalProvider, useTerminalController } from "react-xterm-shell";
55
+
56
+ function Toolbar() {
57
+ const term = useTerminalController();
58
+ return <button onClick={term.clear}>Clear</button>;
59
+ }
60
+
61
+ function Panel() {
62
+ const terminal = useXTerm({ onData: send });
63
+ return (
64
+ <TerminalProvider value={terminal}>
65
+ <Toolbar />
66
+ <XTerm terminal={terminal} className="h-[420px]" />
67
+ </TerminalProvider>
68
+ );
69
+ }
70
+ ```
71
+
72
+ ## API
73
+
74
+ ### `useXTerm(options?) => XTermHandle`
75
+
76
+ | Option | Type | Default | Notes |
77
+ |--------|------|---------|-------|
78
+ | `onData` | `(data: string) => void` | — | User keystrokes (xterm `onData`). |
79
+ | `onResize` | `(size: { cols, rows }) => void` | — | Fires when the grid resizes. |
80
+ | `theme` | `ITheme` | — | xterm color theme. |
81
+ | `fontSize` | `number` | `13` | |
82
+ | `scrollback` | `number` | `1000` | |
83
+ | `cursorBlink` | `boolean` | `true` | |
84
+ | `webgl` | `boolean` | `true` | GL renderer; falls back to DOM on context loss. |
85
+ | `webLinks` | `boolean` | `true` | Clickable URLs. |
86
+ | `unicode11` | `boolean` | `true` | Correct width for box-drawing / emoji. |
87
+ | `options` | `ITerminalOptions` | — | Merged over the above. |
88
+ | `addons` | `ITerminalAddon[]` | — | Extra addons (e.g. a search addon). |
89
+
90
+ The returned `XTermHandle` has `attach` (the callback ref for `<XTerm>`), a live
91
+ `term` getter, and `write` / `clear` / `focus` / `fit`. The handle is stable
92
+ across renders; callbacks are read through refs, so passing fresh `onData` /
93
+ `onResize` each render does not remount the terminal.
94
+
95
+ ### `<XTerm terminal={handle} className? style? />`
96
+
97
+ Renders the mount element. The `terminal.attach` callback ref is the whole
98
+ integration surface.
99
+
100
+ ## Addon notes
101
+
102
+ `@xterm/addon-fit`, `-web-links`, `-unicode11`, and `-webgl` are bundled as
103
+ dependencies and pinned to the xterm-5 line. WebGL loads after `open()` and is
104
+ guarded: if the GL context is unavailable or lost, it disposes and the DOM
105
+ renderer takes over. Disable any of them via the `webgl` / `webLinks` /
106
+ `unicode11` options.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,92 @@
1
+ import { Terminal, ITheme, ITerminalOptions, ITerminalAddon } from '@xterm/xterm';
2
+ export { ITheme, Terminal } from '@xterm/xterm';
3
+ import * as react from 'react';
4
+ import { ReactNode } from 'react';
5
+
6
+ interface UseXTermOptions {
7
+ /** Called with user keystrokes (xterm `onData`). Wire this to your transport. */
8
+ onData?: (data: string) => void;
9
+ /** Called when the rendered grid resizes (xterm `onResize`). */
10
+ onResize?: (size: {
11
+ cols: number;
12
+ rows: number;
13
+ }) => void;
14
+ /** xterm color theme. */
15
+ theme?: ITheme;
16
+ /** Font size in px. Default 13. */
17
+ fontSize?: number;
18
+ /** Scrollback lines retained. Default 1000. */
19
+ scrollback?: number;
20
+ /** Whether the cursor blinks. Default true. */
21
+ cursorBlink?: boolean;
22
+ /**
23
+ * Built-in addons, all enabled by default:
24
+ * - `webgl`: GL renderer with automatic DOM-renderer fallback on context loss.
25
+ * - `webLinks`: clickable URLs in output.
26
+ * - `unicode11`: correct width for box-drawing, spinners, and emoji.
27
+ */
28
+ webgl?: boolean;
29
+ webLinks?: boolean;
30
+ unicode11?: boolean;
31
+ /** Extra xterm options merged over the ones above. */
32
+ options?: ITerminalOptions;
33
+ /** Additional addons to load (e.g. a search addon). */
34
+ addons?: ITerminalAddon[];
35
+ }
36
+ /**
37
+ * Imperative controller for the terminal. Stable across renders, so passing it
38
+ * around (or into a context) never re-renders consumers as output streams. `term`
39
+ * is a live getter that returns the instance once attached, `null` before.
40
+ */
41
+ interface TerminalController {
42
+ readonly term: Terminal | null;
43
+ write: (data: string | Uint8Array) => void;
44
+ clear: () => void;
45
+ focus: () => void;
46
+ fit: () => void;
47
+ }
48
+ type XTermHandle = TerminalController & {
49
+ /**
50
+ * Callback ref for the mount element. Creates the terminal on mount and
51
+ * disposes it when React clears the ref on unmount.
52
+ */
53
+ attach: (el: HTMLDivElement | null) => void;
54
+ };
55
+
56
+ /**
57
+ * A React shell around xterm.js. xterm owns the terminal grid and rendering;
58
+ * this hook owns the instance lifecycle and exposes a stable imperative
59
+ * controller so the surrounding tree can drive it without re-rendering as
60
+ * output streams.
61
+ *
62
+ * The instance is created lazily in the `attach` callback ref and disposed by
63
+ * the cleanup it returns. Sizing is automatic via a `ResizeObserver` + `FitAddon`.
64
+ *
65
+ * Import the stylesheet once in your app: `import "@xterm/xterm/css/xterm.css"`.
66
+ */
67
+ declare function useXTerm(opts?: UseXTermOptions): XTermHandle;
68
+
69
+ interface XTermProps {
70
+ /** The handle returned by `useXTerm`. */
71
+ terminal: XTermHandle;
72
+ className?: string;
73
+ style?: React.CSSProperties;
74
+ }
75
+ /**
76
+ * Thin DOM mount point for an xterm instance. The `useXTerm` callback ref is the
77
+ * entire integration surface; this component just renders the host element.
78
+ */
79
+ declare function XTerm({ terminal, className, style }: Readonly<XTermProps>): react.JSX.Element;
80
+
81
+ /**
82
+ * Provides a terminal controller to descendants so chrome (toolbars, buttons)
83
+ * can drive the terminal imperatively without prop drilling.
84
+ */
85
+ declare function TerminalProvider({ value, children }: Readonly<{
86
+ value: TerminalController;
87
+ children: ReactNode;
88
+ }>): react.JSX.Element;
89
+ /** Read the terminal controller from the nearest `TerminalProvider`. */
90
+ declare function useTerminalController(): TerminalController;
91
+
92
+ export { type TerminalController, TerminalProvider, type UseXTermOptions, XTerm, type XTermHandle, type XTermProps, useTerminalController, useXTerm };
package/dist/index.js ADDED
@@ -0,0 +1,125 @@
1
+ import { createContext, useRef, useEffect, useCallback, useMemo, useContext } from 'react';
2
+ import { Terminal } from '@xterm/xterm';
3
+ import { FitAddon } from '@xterm/addon-fit';
4
+ import { WebLinksAddon } from '@xterm/addon-web-links';
5
+ import { Unicode11Addon } from '@xterm/addon-unicode11';
6
+ import { WebglAddon } from '@xterm/addon-webgl';
7
+ import { jsx } from 'react/jsx-runtime';
8
+
9
+ // src/use-xterm.ts
10
+ var DEFAULT_FONT_SIZE = 13;
11
+ var DEFAULT_SCROLLBACK = 1e3;
12
+ function useXTerm(opts = {}) {
13
+ const {
14
+ onData,
15
+ onResize,
16
+ theme,
17
+ fontSize,
18
+ scrollback,
19
+ cursorBlink,
20
+ webgl = true,
21
+ webLinks = true,
22
+ unicode11 = true,
23
+ options,
24
+ addons
25
+ } = opts;
26
+ const termRef = useRef(null);
27
+ const fitRef = useRef(null);
28
+ const cleanupRef = useRef(null);
29
+ const onDataRef = useRef(onData);
30
+ const onResizeRef = useRef(onResize);
31
+ useEffect(() => {
32
+ onDataRef.current = onData;
33
+ onResizeRef.current = onResize;
34
+ }, [onData, onResize]);
35
+ const extraAddons = useRef(addons);
36
+ extraAddons.current = addons;
37
+ const attach = useCallback(
38
+ (el) => {
39
+ if (!el) {
40
+ cleanupRef.current?.();
41
+ cleanupRef.current = null;
42
+ return;
43
+ }
44
+ if (termRef.current) return;
45
+ const term = new Terminal({
46
+ cursorBlink: cursorBlink ?? true,
47
+ fontSize: fontSize ?? DEFAULT_FONT_SIZE,
48
+ theme,
49
+ scrollback: scrollback ?? DEFAULT_SCROLLBACK,
50
+ allowProposedApi: true,
51
+ ...options
52
+ });
53
+ const fit = new FitAddon();
54
+ term.loadAddon(fit);
55
+ if (webLinks) term.loadAddon(new WebLinksAddon());
56
+ if (unicode11) {
57
+ term.loadAddon(new Unicode11Addon());
58
+ term.unicode.activeVersion = "11";
59
+ }
60
+ for (const addon of extraAddons.current ?? []) {
61
+ term.loadAddon(addon);
62
+ }
63
+ term.open(el);
64
+ if (webgl) {
65
+ try {
66
+ const webglAddon = new WebglAddon();
67
+ webglAddon.onContextLoss(() => webglAddon.dispose());
68
+ term.loadAddon(webglAddon);
69
+ } catch {
70
+ }
71
+ }
72
+ fit.fit();
73
+ const disposables = [
74
+ term.onData((data) => onDataRef.current?.(data)),
75
+ term.onResize((size) => onResizeRef.current?.(size))
76
+ ];
77
+ const resizeObserver = new ResizeObserver(() => fit.fit());
78
+ resizeObserver.observe(el);
79
+ termRef.current = term;
80
+ fitRef.current = fit;
81
+ cleanupRef.current = () => {
82
+ resizeObserver.disconnect();
83
+ disposables.forEach((d) => d.dispose());
84
+ term.dispose();
85
+ termRef.current = null;
86
+ fitRef.current = null;
87
+ };
88
+ },
89
+ [theme, fontSize, scrollback, cursorBlink, webgl, webLinks, unicode11, options]
90
+ );
91
+ return useMemo(
92
+ () => ({
93
+ get term() {
94
+ return termRef.current;
95
+ },
96
+ write: (data) => termRef.current?.write(data),
97
+ clear: () => termRef.current?.clear(),
98
+ focus: () => termRef.current?.focus(),
99
+ fit: () => fitRef.current?.fit(),
100
+ attach
101
+ }),
102
+ [attach]
103
+ );
104
+ }
105
+ function XTerm({ terminal, className, style }) {
106
+ return /* @__PURE__ */ jsx("div", { ref: terminal.attach, className, style });
107
+ }
108
+ var TerminalContext = createContext(null);
109
+ function TerminalProvider({
110
+ value,
111
+ children
112
+ }) {
113
+ return /* @__PURE__ */ jsx(TerminalContext.Provider, { value, children });
114
+ }
115
+ function useTerminalController() {
116
+ const ctx = useContext(TerminalContext);
117
+ if (!ctx) {
118
+ throw new Error("useTerminalController must be used within a <TerminalProvider>");
119
+ }
120
+ return ctx;
121
+ }
122
+
123
+ export { TerminalProvider, XTerm, useTerminalController, useXTerm };
124
+ //# sourceMappingURL=index.js.map
125
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/use-xterm.ts","../src/xterm.tsx","../src/context.tsx"],"names":["jsx"],"mappings":";;;;;;;;;AASA,IAAM,iBAAA,GAAoB,EAAA;AAC1B,IAAM,kBAAA,GAAqB,GAAA;AAapB,SAAS,QAAA,CAAS,IAAA,GAAwB,EAAC,EAAgB;AAChE,EAAA,MAAM;AAAA,IACJ,MAAA;AAAA,IACA,QAAA;AAAA,IACA,KAAA;AAAA,IACA,QAAA;AAAA,IACA,UAAA;AAAA,IACA,WAAA;AAAA,IACA,KAAA,GAAQ,IAAA;AAAA,IACR,QAAA,GAAW,IAAA;AAAA,IACX,SAAA,GAAY,IAAA;AAAA,IACZ,OAAA;AAAA,IACA;AAAA,GACF,GAAI,IAAA;AAEJ,EAAA,MAAM,OAAA,GAAU,OAAwB,IAAI,CAAA;AAC5C,EAAA,MAAM,MAAA,GAAS,OAAwB,IAAI,CAAA;AAC3C,EAAA,MAAM,UAAA,GAAa,OAA4B,IAAI,CAAA;AAMnD,EAAA,MAAM,SAAA,GAAY,OAAO,MAAM,CAAA;AAC/B,EAAA,MAAM,WAAA,GAAc,OAAO,QAAQ,CAAA;AACnC,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,SAAA,CAAU,OAAA,GAAU,MAAA;AACpB,IAAA,WAAA,CAAY,OAAA,GAAU,QAAA;AAAA,EACxB,CAAA,EAAG,CAAC,MAAA,EAAQ,QAAQ,CAAC,CAAA;AAGrB,EAAA,MAAM,WAAA,GAAc,OAAO,MAAM,CAAA;AACjC,EAAA,WAAA,CAAY,OAAA,GAAU,MAAA;AAEtB,EAAA,MAAM,MAAA,GAAS,WAAA;AAAA,IACb,CAAC,EAAA,KAA8B;AAC7B,MAAA,IAAI,CAAC,EAAA,EAAI;AACP,QAAA,UAAA,CAAW,OAAA,IAAU;AACrB,QAAA,UAAA,CAAW,OAAA,GAAU,IAAA;AACrB,QAAA;AAAA,MACF;AACA,MAAA,IAAI,QAAQ,OAAA,EAAS;AAErB,MAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS;AAAA,QACxB,aAAa,WAAA,IAAe,IAAA;AAAA,QAC5B,UAAU,QAAA,IAAY,iBAAA;AAAA,QACtB,KAAA;AAAA,QACA,YAAY,UAAA,IAAc,kBAAA;AAAA,QAC1B,gBAAA,EAAkB,IAAA;AAAA,QAClB,GAAG;AAAA,OACJ,CAAA;AAED,MAAA,MAAM,GAAA,GAAM,IAAI,QAAA,EAAS;AACzB,MAAA,IAAA,CAAK,UAAU,GAAG,CAAA;AAElB,MAAA,IAAI,QAAA,EAAU,IAAA,CAAK,SAAA,CAAU,IAAI,eAAe,CAAA;AAChD,MAAA,IAAI,SAAA,EAAW;AACb,QAAA,IAAA,CAAK,SAAA,CAAU,IAAI,cAAA,EAAgB,CAAA;AACnC,QAAA,IAAA,CAAK,QAAQ,aAAA,GAAgB,IAAA;AAAA,MAC/B;AACA,MAAA,KAAA,MAAW,KAAA,IAAS,WAAA,CAAY,OAAA,IAAW,EAAC,EAAG;AAC7C,QAAA,IAAA,CAAK,UAAU,KAAK,CAAA;AAAA,MACtB;AAEA,MAAA,IAAA,CAAK,KAAK,EAAE,CAAA;AAIZ,MAAA,IAAI,KAAA,EAAO;AACT,QAAA,IAAI;AACF,UAAA,MAAM,UAAA,GAAa,IAAI,UAAA,EAAW;AAClC,UAAA,UAAA,CAAW,aAAA,CAAc,MAAM,UAAA,CAAW,OAAA,EAAS,CAAA;AACnD,UAAA,IAAA,CAAK,UAAU,UAAU,CAAA;AAAA,QAC3B,CAAA,CAAA,MAAQ;AAAA,QAER;AAAA,MACF;AAEA,MAAA,GAAA,CAAI,GAAA,EAAI;AAER,MAAA,MAAM,WAAA,GAAc;AAAA,QAClB,KAAK,MAAA,CAAO,CAAC,SAAS,SAAA,CAAU,OAAA,GAAU,IAAI,CAAC,CAAA;AAAA,QAC/C,KAAK,QAAA,CAAS,CAAC,SAAS,WAAA,CAAY,OAAA,GAAU,IAAI,CAAC;AAAA,OACrD;AAEA,MAAA,MAAM,iBAAiB,IAAI,cAAA,CAAe,MAAM,GAAA,CAAI,KAAK,CAAA;AACzD,MAAA,cAAA,CAAe,QAAQ,EAAE,CAAA;AAEzB,MAAA,OAAA,CAAQ,OAAA,GAAU,IAAA;AAClB,MAAA,MAAA,CAAO,OAAA,GAAU,GAAA;AAEjB,MAAA,UAAA,CAAW,UAAU,MAAM;AACzB,QAAA,cAAA,CAAe,UAAA,EAAW;AAC1B,QAAA,WAAA,CAAY,OAAA,CAAQ,CAAC,CAAA,KAAM,CAAA,CAAE,SAAS,CAAA;AACtC,QAAA,IAAA,CAAK,OAAA,EAAQ;AACb,QAAA,OAAA,CAAQ,OAAA,GAAU,IAAA;AAClB,QAAA,MAAA,CAAO,OAAA,GAAU,IAAA;AAAA,MACnB,CAAA;AAAA,IACF,CAAA;AAAA,IACA,CAAC,OAAO,QAAA,EAAU,UAAA,EAAY,aAAa,KAAA,EAAO,QAAA,EAAU,WAAW,OAAO;AAAA,GAChF;AAKA,EAAA,OAAO,OAAA;AAAA,IACL,OAAO;AAAA,MACL,IAAI,IAAA,GAAO;AACT,QAAA,OAAO,OAAA,CAAQ,OAAA;AAAA,MACjB,CAAA;AAAA,MACA,OAAO,CAAC,IAAA,KAAS,OAAA,CAAQ,OAAA,EAAS,MAAM,IAAI,CAAA;AAAA,MAC5C,KAAA,EAAO,MAAM,OAAA,CAAQ,OAAA,EAAS,KAAA,EAAM;AAAA,MACpC,KAAA,EAAO,MAAM,OAAA,CAAQ,OAAA,EAAS,KAAA,EAAM;AAAA,MACpC,GAAA,EAAK,MAAM,MAAA,CAAO,OAAA,EAAS,GAAA,EAAI;AAAA,MAC/B;AAAA,KACF,CAAA;AAAA,IACA,CAAC,MAAM;AAAA,GACT;AACF;AChIO,SAAS,KAAA,CAAM,EAAE,QAAA,EAAU,SAAA,EAAW,OAAM,EAAyB;AAC1E,EAAA,2BAAQ,KAAA,EAAA,EAAI,GAAA,EAAK,QAAA,CAAS,MAAA,EAAQ,WAAsB,KAAA,EAAc,CAAA;AACxE;ACVA,IAAM,eAAA,GAAkB,cAAyC,IAAI,CAAA;AAM9D,SAAS,gBAAA,CAAiB;AAAA,EAC/B,KAAA;AAAA,EACA;AACF,CAAA,EAAiE;AAC/D,EAAA,uBACEA,GAAAA,CAAC,eAAA,CAAgB,QAAA,EAAhB,EAAyB,OAAe,QAAA,EAAS,CAAA;AAEtD;AAGO,SAAS,qBAAA,GAA4C;AAC1D,EAAA,MAAM,GAAA,GAAM,WAAW,eAAe,CAAA;AACtC,EAAA,IAAI,CAAC,GAAA,EAAK;AACR,IAAA,MAAM,IAAI,MAAM,gEAAgE,CAAA;AAAA,EAClF;AACA,EAAA,OAAO,GAAA;AACT","file":"index.js","sourcesContent":["import { useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { Terminal } from \"@xterm/xterm\";\nimport { FitAddon } from \"@xterm/addon-fit\";\nimport { WebLinksAddon } from \"@xterm/addon-web-links\";\nimport { Unicode11Addon } from \"@xterm/addon-unicode11\";\nimport { WebglAddon } from \"@xterm/addon-webgl\";\n\nimport type { UseXTermOptions, XTermHandle } from \"./types\";\n\nconst DEFAULT_FONT_SIZE = 13;\nconst DEFAULT_SCROLLBACK = 1000;\n\n/**\n * A React shell around xterm.js. xterm owns the terminal grid and rendering;\n * this hook owns the instance lifecycle and exposes a stable imperative\n * controller so the surrounding tree can drive it without re-rendering as\n * output streams.\n *\n * The instance is created lazily in the `attach` callback ref and disposed by\n * the cleanup it returns. Sizing is automatic via a `ResizeObserver` + `FitAddon`.\n *\n * Import the stylesheet once in your app: `import \"@xterm/xterm/css/xterm.css\"`.\n */\nexport function useXTerm(opts: UseXTermOptions = {}): XTermHandle {\n const {\n onData,\n onResize,\n theme,\n fontSize,\n scrollback,\n cursorBlink,\n webgl = true,\n webLinks = true,\n unicode11 = true,\n options,\n addons\n } = opts;\n\n const termRef = useRef<Terminal | null>(null);\n const fitRef = useRef<FitAddon | null>(null);\n const cleanupRef = useRef<(() => void) | null>(null);\n\n // Latest callbacks live in refs so `attach` does not depend on their identity.\n // Otherwise a parent passing fresh callbacks each render would change `attach`,\n // and the callback ref would dispose and recreate the terminal (wiping\n // scrollback). The xterm handlers read the refs, so no stale-closure risk.\n const onDataRef = useRef(onData);\n const onResizeRef = useRef(onResize);\n useEffect(() => {\n onDataRef.current = onData;\n onResizeRef.current = onResize;\n }, [onData, onResize]);\n\n // Snapshot the addon set + extra addons so identity churn does not remount.\n const extraAddons = useRef(addons);\n extraAddons.current = addons;\n\n const attach = useCallback(\n (el: HTMLDivElement | null) => {\n if (!el) {\n cleanupRef.current?.();\n cleanupRef.current = null;\n return;\n }\n if (termRef.current) return;\n\n const term = new Terminal({\n cursorBlink: cursorBlink ?? true,\n fontSize: fontSize ?? DEFAULT_FONT_SIZE,\n theme,\n scrollback: scrollback ?? DEFAULT_SCROLLBACK,\n allowProposedApi: true,\n ...options\n });\n\n const fit = new FitAddon();\n term.loadAddon(fit);\n\n if (webLinks) term.loadAddon(new WebLinksAddon());\n if (unicode11) {\n term.loadAddon(new Unicode11Addon());\n term.unicode.activeVersion = \"11\";\n }\n for (const addon of extraAddons.current ?? []) {\n term.loadAddon(addon);\n }\n\n term.open(el);\n\n // WebGL must load after open(); fall back to the DOM renderer if the GL\n // context is unavailable or lost.\n if (webgl) {\n try {\n const webglAddon = new WebglAddon();\n webglAddon.onContextLoss(() => webglAddon.dispose());\n term.loadAddon(webglAddon);\n } catch {\n // No WebGL (headless/software GL); the DOM renderer remains active.\n }\n }\n\n fit.fit();\n\n const disposables = [\n term.onData((data) => onDataRef.current?.(data)),\n term.onResize((size) => onResizeRef.current?.(size))\n ];\n\n const resizeObserver = new ResizeObserver(() => fit.fit());\n resizeObserver.observe(el);\n\n termRef.current = term;\n fitRef.current = fit;\n\n cleanupRef.current = () => {\n resizeObserver.disconnect();\n disposables.forEach((d) => d.dispose());\n term.dispose();\n termRef.current = null;\n fitRef.current = null;\n };\n },\n [theme, fontSize, scrollback, cursorBlink, webgl, webLinks, unicode11, options]\n );\n\n // One stable handle. `term` is a live getter — spreading it into a static\n // object would freeze it to the null it holds before attach. Methods read the\n // refs, so the handle is stable while the instance comes and goes.\n return useMemo<XTermHandle>(\n () => ({\n get term() {\n return termRef.current;\n },\n write: (data) => termRef.current?.write(data),\n clear: () => termRef.current?.clear(),\n focus: () => termRef.current?.focus(),\n fit: () => fitRef.current?.fit(),\n attach\n }),\n [attach]\n );\n}\n","import type { XTermHandle } from \"./types\";\n\nexport interface XTermProps {\n /** The handle returned by `useXTerm`. */\n terminal: XTermHandle;\n className?: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Thin DOM mount point for an xterm instance. The `useXTerm` callback ref is the\n * entire integration surface; this component just renders the host element.\n */\nexport function XTerm({ terminal, className, style }: Readonly<XTermProps>) {\n return <div ref={terminal.attach} className={className} style={style} />;\n}\n","import { createContext, useContext } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport type { TerminalController } from \"./types\";\n\nconst TerminalContext = createContext<TerminalController | null>(null);\n\n/**\n * Provides a terminal controller to descendants so chrome (toolbars, buttons)\n * can drive the terminal imperatively without prop drilling.\n */\nexport function TerminalProvider({\n value,\n children\n}: Readonly<{ value: TerminalController; children: ReactNode }>) {\n return (\n <TerminalContext.Provider value={value}>{children}</TerminalContext.Provider>\n );\n}\n\n/** Read the terminal controller from the nearest `TerminalProvider`. */\nexport function useTerminalController(): TerminalController {\n const ctx = useContext(TerminalContext);\n if (!ctx) {\n throw new Error(\"useTerminalController must be used within a <TerminalProvider>\");\n }\n return ctx;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "react-xterm-shell",
3
+ "version": "0.1.0",
4
+ "description": "A small React shell around xterm.js: a useXTerm hook + XTerm component with a stable imperative controller, automatic fitting, and opt-in webgl/web-links/unicode11 addons.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "sideEffects": false,
23
+ "keywords": [
24
+ "xterm",
25
+ "xterm.js",
26
+ "react",
27
+ "terminal",
28
+ "pty",
29
+ "webgl",
30
+ "hook",
31
+ "component"
32
+ ],
33
+ "license": "MIT",
34
+ "author": "Noah Wardlow",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/noah-wardlow/react-xterm-shell"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/noah-wardlow/react-xterm-shell/issues"
41
+ },
42
+ "homepage": "https://github.com/noah-wardlow/react-xterm-shell#readme",
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "dev": "tsup --watch",
46
+ "release": "semantic-release",
47
+ "typecheck": "tsc --noEmit",
48
+ "prepublishOnly": "pnpm run typecheck && pnpm run build"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "peerDependencies": {
55
+ "@xterm/xterm": ">=5",
56
+ "react": ">=19"
57
+ },
58
+ "dependencies": {
59
+ "@xterm/addon-fit": "^0.10.0",
60
+ "@xterm/addon-unicode11": "^0.8.0",
61
+ "@xterm/addon-web-links": "^0.11.0",
62
+ "@xterm/addon-webgl": "^0.18.0"
63
+ },
64
+ "devDependencies": {
65
+ "@semantic-release/changelog": "^6.0.3",
66
+ "@semantic-release/git": "^10.0.1",
67
+ "@types/react": "^19.0.0",
68
+ "@xterm/xterm": "^5.5.0",
69
+ "react": "^19.2.0",
70
+ "semantic-release": "^25.0.3",
71
+ "tsup": "^8.4.0",
72
+ "typescript": "~5.8.2"
73
+ },
74
+ "engines": {
75
+ "node": ">=22"
76
+ },
77
+ "packageManager": "pnpm@10.30.0"
78
+ }
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import type { TerminalController } from "./types";
5
+
6
+ const TerminalContext = createContext<TerminalController | null>(null);
7
+
8
+ /**
9
+ * Provides a terminal controller to descendants so chrome (toolbars, buttons)
10
+ * can drive the terminal imperatively without prop drilling.
11
+ */
12
+ export function TerminalProvider({
13
+ value,
14
+ children
15
+ }: Readonly<{ value: TerminalController; children: ReactNode }>) {
16
+ return (
17
+ <TerminalContext.Provider value={value}>{children}</TerminalContext.Provider>
18
+ );
19
+ }
20
+
21
+ /** Read the terminal controller from the nearest `TerminalProvider`. */
22
+ export function useTerminalController(): TerminalController {
23
+ const ctx = useContext(TerminalContext);
24
+ if (!ctx) {
25
+ throw new Error("useTerminalController must be used within a <TerminalProvider>");
26
+ }
27
+ return ctx;
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { useXTerm } from "./use-xterm";
2
+ export { XTerm } from "./xterm";
3
+ export type { XTermProps } from "./xterm";
4
+ export { TerminalProvider, useTerminalController } from "./context";
5
+ export type {
6
+ UseXTermOptions,
7
+ TerminalController,
8
+ XTermHandle
9
+ } from "./types";
10
+
11
+ // Convenience re-export so consumers can type a theme without a direct
12
+ // @xterm/xterm import.
13
+ export type { ITheme, Terminal } from "@xterm/xterm";
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ITheme,
3
+ ITerminalOptions,
4
+ ITerminalAddon,
5
+ Terminal
6
+ } from "@xterm/xterm";
7
+
8
+ export interface UseXTermOptions {
9
+ /** Called with user keystrokes (xterm `onData`). Wire this to your transport. */
10
+ onData?: (data: string) => void;
11
+ /** Called when the rendered grid resizes (xterm `onResize`). */
12
+ onResize?: (size: { cols: number; rows: number }) => void;
13
+ /** xterm color theme. */
14
+ theme?: ITheme;
15
+ /** Font size in px. Default 13. */
16
+ fontSize?: number;
17
+ /** Scrollback lines retained. Default 1000. */
18
+ scrollback?: number;
19
+ /** Whether the cursor blinks. Default true. */
20
+ cursorBlink?: boolean;
21
+ /**
22
+ * Built-in addons, all enabled by default:
23
+ * - `webgl`: GL renderer with automatic DOM-renderer fallback on context loss.
24
+ * - `webLinks`: clickable URLs in output.
25
+ * - `unicode11`: correct width for box-drawing, spinners, and emoji.
26
+ */
27
+ webgl?: boolean;
28
+ webLinks?: boolean;
29
+ unicode11?: boolean;
30
+ /** Extra xterm options merged over the ones above. */
31
+ options?: ITerminalOptions;
32
+ /** Additional addons to load (e.g. a search addon). */
33
+ addons?: ITerminalAddon[];
34
+ }
35
+
36
+ /**
37
+ * Imperative controller for the terminal. Stable across renders, so passing it
38
+ * around (or into a context) never re-renders consumers as output streams. `term`
39
+ * is a live getter that returns the instance once attached, `null` before.
40
+ */
41
+ export interface TerminalController {
42
+ readonly term: Terminal | null;
43
+ write: (data: string | Uint8Array) => void;
44
+ clear: () => void;
45
+ focus: () => void;
46
+ fit: () => void;
47
+ }
48
+
49
+ export type XTermHandle = TerminalController & {
50
+ /**
51
+ * Callback ref for the mount element. Creates the terminal on mount and
52
+ * disposes it when React clears the ref on unmount.
53
+ */
54
+ attach: (el: HTMLDivElement | null) => void;
55
+ };
@@ -0,0 +1,142 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { Terminal } from "@xterm/xterm";
3
+ import { FitAddon } from "@xterm/addon-fit";
4
+ import { WebLinksAddon } from "@xterm/addon-web-links";
5
+ import { Unicode11Addon } from "@xterm/addon-unicode11";
6
+ import { WebglAddon } from "@xterm/addon-webgl";
7
+
8
+ import type { UseXTermOptions, XTermHandle } from "./types";
9
+
10
+ const DEFAULT_FONT_SIZE = 13;
11
+ const DEFAULT_SCROLLBACK = 1000;
12
+
13
+ /**
14
+ * A React shell around xterm.js. xterm owns the terminal grid and rendering;
15
+ * this hook owns the instance lifecycle and exposes a stable imperative
16
+ * controller so the surrounding tree can drive it without re-rendering as
17
+ * output streams.
18
+ *
19
+ * The instance is created lazily in the `attach` callback ref and disposed by
20
+ * the cleanup it returns. Sizing is automatic via a `ResizeObserver` + `FitAddon`.
21
+ *
22
+ * Import the stylesheet once in your app: `import "@xterm/xterm/css/xterm.css"`.
23
+ */
24
+ export function useXTerm(opts: UseXTermOptions = {}): XTermHandle {
25
+ const {
26
+ onData,
27
+ onResize,
28
+ theme,
29
+ fontSize,
30
+ scrollback,
31
+ cursorBlink,
32
+ webgl = true,
33
+ webLinks = true,
34
+ unicode11 = true,
35
+ options,
36
+ addons
37
+ } = opts;
38
+
39
+ const termRef = useRef<Terminal | null>(null);
40
+ const fitRef = useRef<FitAddon | null>(null);
41
+ const cleanupRef = useRef<(() => void) | null>(null);
42
+
43
+ // Latest callbacks live in refs so `attach` does not depend on their identity.
44
+ // Otherwise a parent passing fresh callbacks each render would change `attach`,
45
+ // and the callback ref would dispose and recreate the terminal (wiping
46
+ // scrollback). The xterm handlers read the refs, so no stale-closure risk.
47
+ const onDataRef = useRef(onData);
48
+ const onResizeRef = useRef(onResize);
49
+ useEffect(() => {
50
+ onDataRef.current = onData;
51
+ onResizeRef.current = onResize;
52
+ }, [onData, onResize]);
53
+
54
+ // Snapshot the addon set + extra addons so identity churn does not remount.
55
+ const extraAddons = useRef(addons);
56
+ extraAddons.current = addons;
57
+
58
+ const attach = useCallback(
59
+ (el: HTMLDivElement | null) => {
60
+ if (!el) {
61
+ cleanupRef.current?.();
62
+ cleanupRef.current = null;
63
+ return;
64
+ }
65
+ if (termRef.current) return;
66
+
67
+ const term = new Terminal({
68
+ cursorBlink: cursorBlink ?? true,
69
+ fontSize: fontSize ?? DEFAULT_FONT_SIZE,
70
+ theme,
71
+ scrollback: scrollback ?? DEFAULT_SCROLLBACK,
72
+ allowProposedApi: true,
73
+ ...options
74
+ });
75
+
76
+ const fit = new FitAddon();
77
+ term.loadAddon(fit);
78
+
79
+ if (webLinks) term.loadAddon(new WebLinksAddon());
80
+ if (unicode11) {
81
+ term.loadAddon(new Unicode11Addon());
82
+ term.unicode.activeVersion = "11";
83
+ }
84
+ for (const addon of extraAddons.current ?? []) {
85
+ term.loadAddon(addon);
86
+ }
87
+
88
+ term.open(el);
89
+
90
+ // WebGL must load after open(); fall back to the DOM renderer if the GL
91
+ // context is unavailable or lost.
92
+ if (webgl) {
93
+ try {
94
+ const webglAddon = new WebglAddon();
95
+ webglAddon.onContextLoss(() => webglAddon.dispose());
96
+ term.loadAddon(webglAddon);
97
+ } catch {
98
+ // No WebGL (headless/software GL); the DOM renderer remains active.
99
+ }
100
+ }
101
+
102
+ fit.fit();
103
+
104
+ const disposables = [
105
+ term.onData((data) => onDataRef.current?.(data)),
106
+ term.onResize((size) => onResizeRef.current?.(size))
107
+ ];
108
+
109
+ const resizeObserver = new ResizeObserver(() => fit.fit());
110
+ resizeObserver.observe(el);
111
+
112
+ termRef.current = term;
113
+ fitRef.current = fit;
114
+
115
+ cleanupRef.current = () => {
116
+ resizeObserver.disconnect();
117
+ disposables.forEach((d) => d.dispose());
118
+ term.dispose();
119
+ termRef.current = null;
120
+ fitRef.current = null;
121
+ };
122
+ },
123
+ [theme, fontSize, scrollback, cursorBlink, webgl, webLinks, unicode11, options]
124
+ );
125
+
126
+ // One stable handle. `term` is a live getter — spreading it into a static
127
+ // object would freeze it to the null it holds before attach. Methods read the
128
+ // refs, so the handle is stable while the instance comes and goes.
129
+ return useMemo<XTermHandle>(
130
+ () => ({
131
+ get term() {
132
+ return termRef.current;
133
+ },
134
+ write: (data) => termRef.current?.write(data),
135
+ clear: () => termRef.current?.clear(),
136
+ focus: () => termRef.current?.focus(),
137
+ fit: () => fitRef.current?.fit(),
138
+ attach
139
+ }),
140
+ [attach]
141
+ );
142
+ }
package/src/xterm.tsx ADDED
@@ -0,0 +1,16 @@
1
+ import type { XTermHandle } from "./types";
2
+
3
+ export interface XTermProps {
4
+ /** The handle returned by `useXTerm`. */
5
+ terminal: XTermHandle;
6
+ className?: string;
7
+ style?: React.CSSProperties;
8
+ }
9
+
10
+ /**
11
+ * Thin DOM mount point for an xterm instance. The `useXTerm` callback ref is the
12
+ * entire integration surface; this component just renders the host element.
13
+ */
14
+ export function XTerm({ terminal, className, style }: Readonly<XTermProps>) {
15
+ return <div ref={terminal.attach} className={className} style={style} />;
16
+ }