react-xterm-shell 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # react-xterm-shell
2
2
 
3
+ [npm package](https://www.npmjs.com/package/react-xterm-shell)
4
+
3
5
  > **Beta** — under active development; the API may change between minor versions until 1.0.
4
6
 
5
7
  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
8
 
7
9
  - `useXTerm()` — creates and owns the xterm instance behind a stable controller.
8
10
  - `<XTerm />` — the DOM mount point.
9
- - `TerminalProvider` / `useTerminalController()` — drive the terminal from chrome (toolbars) without prop drilling.
11
+ - `TerminalProvider` / `useTerminalController()` — drive the terminal from surrounding UI without prop drilling.
10
12
  - Automatic sizing via `FitAddon` + `ResizeObserver`.
11
13
  - Opt-in `webgl` (with DOM fallback), `web-links`, and `unicode11` addons, on by default.
12
14
 
@@ -27,48 +29,90 @@ import "@xterm/xterm/css/xterm.css";
27
29
  ## Quick start
28
30
 
29
31
  ```tsx
30
- import { useXTerm, XTerm } from "react-xterm-shell";
32
+ import { useEffect, useRef } from "react";
33
+ import { useXTerm, XTerm, type XTermHandle } from "react-xterm-shell";
31
34
  import "@xterm/xterm/css/xterm.css";
32
35
 
33
- function TerminalPanel({ socket }: { socket: WebSocket }) {
36
+ export function TerminalPanel() {
37
+ const terminalRef = useRef<XTermHandle | null>(null);
34
38
  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 })),
39
+ onData: (data) => terminalRef.current?.write(data === "\r" ? "\r\n$ " : data),
38
40
  theme: { background: "#1a1b26", foreground: "#a9b1d6" }
39
41
  });
42
+ terminalRef.current = terminal;
40
43
 
41
- // Stream server output into the terminal:
42
- socket.onmessage = (e) => terminal.write(e.data);
44
+ useEffect(() => {
45
+ terminal.write("react-xterm-shell\r\n$ ");
46
+ terminal.focus();
47
+ }, [terminal]);
43
48
 
44
49
  return <XTerm terminal={terminal} style={{ width: "100%", height: 420 }} />;
45
50
  }
46
51
  ```
47
52
 
48
- ## Composition
53
+ ## External controls
49
54
 
50
- `useXTerm` returns a stable controller, so a toolbar can drive it imperatively
55
+ `useXTerm` returns a stable controller, so surrounding UI can drive it imperatively
51
56
  without re-rendering as bytes stream:
52
57
 
53
58
  ```tsx
54
59
  import { useXTerm, XTerm, TerminalProvider, useTerminalController } from "react-xterm-shell";
55
60
 
56
- function Toolbar() {
61
+ function SessionActions() {
57
62
  const term = useTerminalController();
58
- return <button onClick={term.clear}>Clear</button>;
63
+ return (
64
+ <div>
65
+ <button onClick={() => term.write("deploy --target staging\r\n")}>Insert command</button>
66
+ <button onClick={term.clear}>Clear</button>
67
+ <button onClick={term.focus}>Focus</button>
68
+ </div>
69
+ );
59
70
  }
60
71
 
61
72
  function Panel() {
62
73
  const terminal = useXTerm({ onData: send });
63
74
  return (
64
75
  <TerminalProvider value={terminal}>
65
- <Toolbar />
76
+ <SessionActions />
66
77
  <XTerm terminal={terminal} className="h-[420px]" />
67
78
  </TerminalProvider>
68
79
  );
69
80
  }
70
81
  ```
71
82
 
83
+ ## Backend wiring
84
+
85
+ The package does not include a transport. Connect `onData` and `onResize` to a
86
+ PTY, SSH, container, or WebSocket service, and stream backend output into
87
+ `terminal.write()`:
88
+
89
+ ```tsx
90
+ function RemoteShell({ socket }: { socket: WebSocket }) {
91
+ const terminal = useXTerm({
92
+ onData: (data) => socket.send(JSON.stringify({ type: "input", data })),
93
+ onResize: ({ cols, rows }) =>
94
+ socket.send(JSON.stringify({ type: "resize", cols, rows }))
95
+ });
96
+
97
+ socket.onmessage = (event) => terminal.write(event.data);
98
+
99
+ // Re-send the size once the socket is open. The mount-time `onResize` fires
100
+ // before the socket connects, so that first resize is dropped — without this
101
+ // the PTY stays at its connect-time default and the shell renders narrower
102
+ // than the pane.
103
+ socket.onopen = () => {
104
+ const size = terminal.getDimensions();
105
+ if (size) socket.send(JSON.stringify({ type: "resize", ...size }));
106
+ };
107
+
108
+ return <XTerm terminal={terminal} className="h-[420px]" />;
109
+ }
110
+ ```
111
+
112
+ > **Sizing gotcha:** the terminal fits at mount, so the first `onResize` fires
113
+ > *before* your transport is connected and that size is lost. Always re-send the
114
+ > size on (re)connect using `getDimensions()`, as shown above.
115
+
72
116
  ## API
73
117
 
74
118
  ### `useXTerm(options?) => XTermHandle`
@@ -88,9 +132,12 @@ function Panel() {
88
132
  | `addons` | `ITerminalAddon[]` | — | Extra addons (e.g. a search addon). |
89
133
 
90
134
  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.
135
+ `term` getter, and `write` / `clear` / `reset` / `focus` / `fit` /
136
+ `getDimensions` (`clear` keeps the prompt line; `reset` blanks the screen and
137
+ drops scrollback; `getDimensions` returns the current `{ cols, rows }` or `null`
138
+ before attach — see the sizing gotcha under [Backend wiring](#backend-wiring)).
139
+ The handle is stable across renders; callbacks are read through refs, so passing
140
+ fresh `onData` / `onResize` each render does not remount the terminal.
94
141
 
95
142
  ### `<XTerm terminal={handle} className? style? />`
96
143
 
package/dist/index.d.ts CHANGED
@@ -41,9 +41,23 @@ interface UseXTermOptions {
41
41
  interface TerminalController {
42
42
  readonly term: Terminal | null;
43
43
  write: (data: string | Uint8Array) => void;
44
+ /** Clear scrollback, keeping the current prompt line (xterm `clear`). */
44
45
  clear: () => void;
46
+ /** Full reset to a blank screen, dropping scrollback (xterm `reset`). */
47
+ reset: () => void;
45
48
  focus: () => void;
46
49
  fit: () => void;
50
+ /**
51
+ * Current grid size, or `null` before the terminal is attached. Use this to
52
+ * re-send the size to your backend when the transport (re)connects: the
53
+ * initial `onResize` fires synchronously at mount — before your socket is
54
+ * open — so that first size is otherwise dropped and the PTY stays at its
55
+ * connect-time default.
56
+ */
57
+ getDimensions: () => {
58
+ cols: number;
59
+ rows: number;
60
+ } | null;
47
61
  }
48
62
  type XTermHandle = TerminalController & {
49
63
  /**
package/dist/index.js CHANGED
@@ -69,11 +69,11 @@ function useXTerm(opts = {}) {
69
69
  } catch {
70
70
  }
71
71
  }
72
- fit.fit();
73
72
  const disposables = [
74
73
  term.onData((data) => onDataRef.current?.(data)),
75
74
  term.onResize((size) => onResizeRef.current?.(size))
76
75
  ];
76
+ fit.fit();
77
77
  const resizeObserver = new ResizeObserver(() => fit.fit());
78
78
  resizeObserver.observe(el);
79
79
  termRef.current = term;
@@ -95,8 +95,13 @@ function useXTerm(opts = {}) {
95
95
  },
96
96
  write: (data) => termRef.current?.write(data),
97
97
  clear: () => termRef.current?.clear(),
98
+ reset: () => termRef.current?.reset(),
98
99
  focus: () => termRef.current?.focus(),
99
100
  fit: () => fitRef.current?.fit(),
101
+ getDimensions: () => {
102
+ const term = termRef.current;
103
+ return term ? { cols: term.cols, rows: term.rows } : null;
104
+ },
100
105
  attach
101
106
  }),
102
107
  [attach]
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/use-xterm.ts","../src/xterm.tsx","../src/context.tsx"],"names":["jsx"],"mappings":"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","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"]}
1
+ {"version":3,"sources":["../src/use-xterm.ts","../src/xterm.tsx","../src/context.tsx"],"names":["jsx"],"mappings":"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","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 // Register handlers before the first fit() so the mount-time resize is\n // delivered to onResize. If fit() runs first, that initial resize event\n // fires with no listener attached and the starting grid size is lost —\n // leaving a transport that only syncs on onResize stuck at the default.\n const disposables = [\n term.onData((data) => onDataRef.current?.(data)),\n term.onResize((size) => onResizeRef.current?.(size))\n ];\n\n fit.fit();\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 reset: () => termRef.current?.reset(),\n focus: () => termRef.current?.focus(),\n fit: () => fitRef.current?.fit(),\n getDimensions: () => {\n const term = termRef.current;\n return term ? { cols: term.cols, rows: term.rows } : null;\n },\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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-xterm-shell",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "author": "Noah Wardlow",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/noah-wardlow/react-xterm-shell"
37
+ "url": "git+https://github.com/noah-wardlow/react-xterm-shell.git"
38
38
  },
39
39
  "bugs": {
40
40
  "url": "https://github.com/noah-wardlow/react-xterm-shell/issues"
package/src/types.ts CHANGED
@@ -41,9 +41,20 @@ export interface UseXTermOptions {
41
41
  export interface TerminalController {
42
42
  readonly term: Terminal | null;
43
43
  write: (data: string | Uint8Array) => void;
44
+ /** Clear scrollback, keeping the current prompt line (xterm `clear`). */
44
45
  clear: () => void;
46
+ /** Full reset to a blank screen, dropping scrollback (xterm `reset`). */
47
+ reset: () => void;
45
48
  focus: () => void;
46
49
  fit: () => void;
50
+ /**
51
+ * Current grid size, or `null` before the terminal is attached. Use this to
52
+ * re-send the size to your backend when the transport (re)connects: the
53
+ * initial `onResize` fires synchronously at mount — before your socket is
54
+ * open — so that first size is otherwise dropped and the PTY stays at its
55
+ * connect-time default.
56
+ */
57
+ getDimensions: () => { cols: number; rows: number } | null;
47
58
  }
48
59
 
49
60
  export type XTermHandle = TerminalController & {
package/src/use-xterm.ts CHANGED
@@ -99,13 +99,17 @@ export function useXTerm(opts: UseXTermOptions = {}): XTermHandle {
99
99
  }
100
100
  }
101
101
 
102
- fit.fit();
103
-
102
+ // Register handlers before the first fit() so the mount-time resize is
103
+ // delivered to onResize. If fit() runs first, that initial resize event
104
+ // fires with no listener attached and the starting grid size is lost —
105
+ // leaving a transport that only syncs on onResize stuck at the default.
104
106
  const disposables = [
105
107
  term.onData((data) => onDataRef.current?.(data)),
106
108
  term.onResize((size) => onResizeRef.current?.(size))
107
109
  ];
108
110
 
111
+ fit.fit();
112
+
109
113
  const resizeObserver = new ResizeObserver(() => fit.fit());
110
114
  resizeObserver.observe(el);
111
115
 
@@ -133,8 +137,13 @@ export function useXTerm(opts: UseXTermOptions = {}): XTermHandle {
133
137
  },
134
138
  write: (data) => termRef.current?.write(data),
135
139
  clear: () => termRef.current?.clear(),
140
+ reset: () => termRef.current?.reset(),
136
141
  focus: () => termRef.current?.focus(),
137
142
  fit: () => fitRef.current?.fit(),
143
+ getDimensions: () => {
144
+ const term = termRef.current;
145
+ return term ? { cols: term.cols, rows: term.rows } : null;
146
+ },
138
147
  attach
139
148
  }),
140
149
  [attach]