@particle-academy/fancy-term 0.2.1 → 0.3.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/dist/index.d.ts CHANGED
@@ -2,6 +2,69 @@ import * as react from 'react';
2
2
  import { HTMLAttributes, CSSProperties, RefObject } from 'react';
3
3
  import { ITheme, Terminal as Terminal$1 } from '@xterm/xterm';
4
4
 
5
+ /** What a paste / drop carried: plain text plus any files (e.g. pasted images). */
6
+ interface ClipboardPayload {
7
+ /** Plain text from the clipboard (or pasted selection). May be "". */
8
+ text: string;
9
+ /** Files present on the clipboard / drop — typically pasted/dropped images. */
10
+ files: File[];
11
+ /** Convenience: the subset of `files` whose MIME type is `image/*`. */
12
+ images: File[];
13
+ }
14
+ /** True when a file looks like an image (by MIME type). */
15
+ declare function isImageFile(f: {
16
+ type?: string;
17
+ }): boolean;
18
+
19
+ /** Context handed to the menu at right-click time + to custom item builders. */
20
+ interface TerminalContextMenuContext {
21
+ /** Whether there is a non-empty text selection. */
22
+ hasSelection: boolean;
23
+ /** The selected text (or ""). */
24
+ selection: string;
25
+ /** Whether the terminal is read-only (paste / clear are dropped by default). */
26
+ readOnly: boolean;
27
+ }
28
+ /** A single menu entry. `onSelect` runs on click; set `separator` for a divider. */
29
+ interface TerminalContextMenuItem {
30
+ /** Stable id (used as the React key + a `data-menu-item` handle). */
31
+ id: string;
32
+ /** Visible label. Ignored for separators. */
33
+ label?: string;
34
+ /** Optional leading glyph / emoji / short string (JSON-friendly — no ReactNode). */
35
+ icon?: string;
36
+ /** Greyed-out + non-interactive. */
37
+ disabled?: boolean;
38
+ /** Render a divider instead of a clickable item. */
39
+ separator?: boolean;
40
+ /** Invoked on click with the menu context. */
41
+ onSelect?: (ctx: TerminalContextMenuContext) => void;
42
+ }
43
+ /** The actions the built-in items dispatch to (wired by the component). */
44
+ interface TerminalMenuActions {
45
+ copy: () => void;
46
+ paste: () => void;
47
+ selectAll: () => void;
48
+ clear: () => void;
49
+ }
50
+ /**
51
+ * The `contextMenu` prop shape:
52
+ * - `false` — disable the menu entirely.
53
+ * - `true` / omitted — the default Copy / Paste / Select all / Clear menu.
54
+ * - an array — replace the default items wholesale.
55
+ * - a function — receives the context **and the default items** so you can
56
+ * add/remove/reorder (e.g. append a "Send to agent" item).
57
+ */
58
+ type TerminalContextMenuConfig = boolean | TerminalContextMenuItem[] | ((ctx: TerminalContextMenuContext, defaults: TerminalContextMenuItem[]) => TerminalContextMenuItem[]);
59
+ /** The built-in Copy / Paste / Select all / Clear items, contextual to selection + readOnly. */
60
+ declare function defaultMenuItems(ctx: TerminalContextMenuContext, actions: TerminalMenuActions): TerminalContextMenuItem[];
61
+ /**
62
+ * Resolve the configured menu into the final item list, or `null` when disabled.
63
+ * Empty results (e.g. a function that filtered everything out) also resolve to
64
+ * `null` so the caller can skip opening an empty menu.
65
+ */
66
+ declare function resolveMenuItems(config: TerminalContextMenuConfig | undefined, ctx: TerminalContextMenuContext, actions: TerminalMenuActions): TerminalContextMenuItem[] | null;
67
+
5
68
  /** xterm.js color theme (re-exported for ergonomic typing). */
6
69
  type TerminalTheme = ITheme;
7
70
  type CursorStyle = "block" | "underline" | "bar";
@@ -49,6 +112,14 @@ interface TerminalHandle {
49
112
  getBuffer: () => string;
50
113
  /** The current text selection, or "" when nothing is selected. */
51
114
  getSelection: () => string;
115
+ /** Copy the current selection to the system clipboard. Resolves false when there's nothing selected or the clipboard is unavailable. */
116
+ copySelection: () => Promise<boolean>;
117
+ /** Paste text into the terminal. With no argument, reads the system clipboard. Honors xterm's bracketed-paste mode. */
118
+ paste: (text?: string) => Promise<void>;
119
+ /** Select the entire buffer. */
120
+ selectAll: () => void;
121
+ /** Clear the current selection. */
122
+ clearSelection: () => void;
52
123
  /**
53
124
  * Switch the active shell by id. Resolves the matching {@link ShellProfile}
54
125
  * from `shells` and fires `onShellChange`; in uncontrolled mode it also updates
@@ -88,8 +159,22 @@ interface TerminalOptions {
88
159
  cols: number;
89
160
  rows: number;
90
161
  }) => void;
162
+ /**
163
+ * Enable clipboard wiring — the Ctrl+Shift+C / Cmd+C copy chord and the paste
164
+ * interceptor (which surfaces pasted images via {@link onPaste}). Default true.
165
+ * Text paste works regardless; this gates the *enhanced* clipboard behavior.
166
+ */
167
+ clipboard?: boolean;
168
+ /**
169
+ * Fired on every paste with the clipboard payload — `{ text, files, images }`.
170
+ * Plain text still pastes into the terminal natively; this is where a host
171
+ * receives pasted **images** (a shell can't render them) to upload / hand to an
172
+ * agent / write a path. Return `false` to fully consume the paste (suppress the
173
+ * default text paste — e.g. to transform it, then call `handle.paste(...)`).
174
+ */
175
+ onPaste?: (payload: ClipboardPayload) => void | boolean;
91
176
  }
92
- interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElement>, "onInput" | "onResize" | "children"> {
177
+ interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElement>, "onInput" | "onResize" | "onPaste" | "contextMenu" | "children"> {
93
178
  /**
94
179
  * The controlled output buffer. The component diffs against the previous value
95
180
  * and writes only the appended delta, so a host can drive the terminal from
@@ -112,6 +197,13 @@ interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElem
112
197
  * `shells` is provided. Default `false` — opt-in so existing layout is unchanged.
113
198
  */
114
199
  showShellBar?: boolean;
200
+ /**
201
+ * The right-click selection context menu. `false` disables it; `true` / omitted
202
+ * gives the default Copy / Paste / Select all / Clear menu; an array replaces
203
+ * the items; a `(ctx, defaults) => items` function lets you add/remove/reorder
204
+ * (e.g. append a "Send to agent" item). See {@link TerminalContextMenuConfig}.
205
+ */
206
+ contextMenu?: TerminalContextMenuConfig;
115
207
  }
116
208
  /**
117
209
  * Resolve a shell id to its {@link ShellProfile} within a list. Pure helper —
@@ -169,6 +261,22 @@ interface ShellSwitcherProps {
169
261
  */
170
262
  declare function ShellSwitcher({ shells, value, onChange, className, style, disabled, }: ShellSwitcherProps): react.JSX.Element;
171
263
 
264
+ /**
265
+ * The terminal selection context menu. Deliberately plain React + inline styles
266
+ * (fancy-term has no react-fancy dependency) styled to match the shell bar. It
267
+ * closes on click-outside + Escape, and clamps itself inside the viewport on
268
+ * mount so a right-click near an edge never spills off-screen.
269
+ */
270
+ declare function TerminalContextMenu({ at, items, ctx, onClose, }: {
271
+ at: {
272
+ x: number;
273
+ y: number;
274
+ };
275
+ items: TerminalContextMenuItem[];
276
+ ctx: TerminalContextMenuContext;
277
+ onClose: () => void;
278
+ }): react.JSX.Element;
279
+
172
280
  /**
173
281
  * Create + own an xterm.js instance for a container, returning a stable
174
282
  * {@link TerminalHandle}. The headless engine layer behind `<Terminal>` — use it
@@ -257,4 +365,40 @@ declare function useTerminalSession(options: UseTerminalSessionOptions): Termina
257
365
  */
258
366
  declare const fancyDarkTheme: TerminalTheme;
259
367
 
260
- export { BUILTIN_SHELLS, type CursorStyle, type ShellProfile, ShellSwitcher, type ShellSwitcherProps, Terminal, type TerminalHandle, type TerminalOptions, type TerminalProps, type TerminalSessionApi, type TerminalSessionTransport, type TerminalTheme, type UseTerminalSessionOptions, fancyDarkTheme, resolveShell, useTerminal, useTerminalFit, useTerminalSession };
368
+ /** The subset of a KeyboardEvent the copy decision reads. */
369
+ interface CopyKeyEvent {
370
+ type: string;
371
+ key: string;
372
+ ctrlKey: boolean;
373
+ shiftKey: boolean;
374
+ metaKey: boolean;
375
+ }
376
+ /**
377
+ * Whether a key event should copy the current selection to the clipboard.
378
+ *
379
+ * - **Ctrl+Shift+C** (Windows / Linux terminal convention) — always.
380
+ * - **Cmd / Meta+C** (macOS) — only when there is a selection.
381
+ * - **Plain Ctrl+C is never a copy** — it must stay SIGINT.
382
+ *
383
+ * Only fires on `keydown`. When this returns true the caller copies the
384
+ * selection and consumes the event (returns `false` from xterm's
385
+ * `attachCustomKeyEventHandler` so no control byte is sent).
386
+ */
387
+ declare function shouldCopyEvent(e: CopyKeyEvent, hasSelection: boolean): boolean;
388
+
389
+ interface MenuPoint {
390
+ x: number;
391
+ y: number;
392
+ }
393
+ interface MenuSize {
394
+ width: number;
395
+ height: number;
396
+ }
397
+ /**
398
+ * Clamp a menu's top-left so the whole menu stays inside the viewport (minus a
399
+ * margin). Pushes left/up when it would overflow the right/bottom edge, and never
400
+ * goes past the top-left margin. Pure.
401
+ */
402
+ declare function clampMenuPosition(at: MenuPoint, menu: MenuSize, viewport: MenuSize, margin?: number): MenuPoint;
403
+
404
+ export { BUILTIN_SHELLS, type ClipboardPayload, type CopyKeyEvent, type CursorStyle, type ShellProfile, ShellSwitcher, type ShellSwitcherProps, Terminal, TerminalContextMenu, type TerminalContextMenuConfig, type TerminalContextMenuContext, type TerminalContextMenuItem, type TerminalHandle, type TerminalMenuActions, type TerminalOptions, type TerminalProps, type TerminalSessionApi, type TerminalSessionTransport, type TerminalTheme, type UseTerminalSessionOptions, clampMenuPosition, defaultMenuItems, fancyDarkTheme, isImageFile, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { forwardRef, useRef, useState, useCallback, useImperativeHandle, useEffect, useId } from 'react';
1
+ import { forwardRef, useRef, useState, useCallback, useImperativeHandle, useEffect, useLayoutEffect, useId } from 'react';
2
2
  import { Terminal as Terminal$1 } from '@xterm/xterm';
3
3
  import { FitAddon } from '@xterm/addon-fit';
4
- import { jsx, jsxs } from 'react/jsx-runtime';
4
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
5
 
6
6
  // src/components/Terminal.tsx
7
7
 
@@ -50,6 +50,77 @@ function useTerminalFit(ref, fit, enabled = true) {
50
50
  }, [ref, enabled]);
51
51
  }
52
52
 
53
+ // src/clipboard.ts
54
+ function isImageFile(f) {
55
+ return typeof f.type === "string" && f.type.startsWith("image/");
56
+ }
57
+ function readDataTransfer(dt) {
58
+ const files = [];
59
+ let text = "";
60
+ if (dt) {
61
+ try {
62
+ text = dt.getData("text/plain") ?? "";
63
+ } catch {
64
+ }
65
+ const items = dt.items;
66
+ if (items) {
67
+ for (let i = 0; i < items.length; i++) {
68
+ const it = items[i];
69
+ if (it && it.kind === "file") {
70
+ const f = it.getAsFile();
71
+ if (f) files.push(f);
72
+ }
73
+ }
74
+ }
75
+ if (files.length === 0 && dt.files) {
76
+ for (let i = 0; i < dt.files.length; i++) files.push(dt.files[i]);
77
+ }
78
+ }
79
+ return { text, files, images: files.filter(isImageFile) };
80
+ }
81
+ async function writeClipboardText(text) {
82
+ try {
83
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
84
+ if (nav?.clipboard?.writeText) {
85
+ await nav.clipboard.writeText(text);
86
+ return true;
87
+ }
88
+ } catch {
89
+ }
90
+ try {
91
+ if (typeof document === "undefined") return false;
92
+ const ta = document.createElement("textarea");
93
+ ta.value = text;
94
+ ta.style.position = "fixed";
95
+ ta.style.left = "-9999px";
96
+ ta.style.opacity = "0";
97
+ document.body.appendChild(ta);
98
+ ta.select();
99
+ const ok = document.execCommand("copy");
100
+ document.body.removeChild(ta);
101
+ return ok;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ async function readClipboardText() {
107
+ try {
108
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
109
+ if (nav?.clipboard?.readText) return await nav.clipboard.readText();
110
+ } catch {
111
+ }
112
+ return "";
113
+ }
114
+
115
+ // src/copy-keybinding.ts
116
+ function shouldCopyEvent(e, hasSelection) {
117
+ if (e.type !== "keydown") return false;
118
+ if (e.key !== "c" && e.key !== "C") return false;
119
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
120
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
121
+ return false;
122
+ }
123
+
53
124
  // src/hooks/use-terminal.ts
54
125
  var DEFAULT_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
55
126
  function readBuffer(term) {
@@ -92,6 +163,17 @@ function useTerminal(containerRef, options = {}) {
92
163
  focus: () => xtermRef.current?.focus(),
93
164
  getBuffer: () => readBuffer(xtermRef.current),
94
165
  getSelection: () => xtermRef.current?.getSelection() ?? "",
166
+ copySelection: async () => {
167
+ const sel = xtermRef.current?.getSelection() ?? "";
168
+ if (!sel) return false;
169
+ return writeClipboardText(sel);
170
+ },
171
+ paste: async (text) => {
172
+ const data = text ?? await readClipboardText();
173
+ if (data) xtermRef.current?.paste(data);
174
+ },
175
+ selectAll: () => xtermRef.current?.selectAll(),
176
+ clearSelection: () => xtermRef.current?.clearSelection(),
95
177
  // Shell selection is owned by the <Terminal> component layer (it needs the
96
178
  // `shells` list + onShellChange). The headless engine is shell-agnostic, so
97
179
  // these are no-ops here and get overridden when <Terminal> composes them.
@@ -107,8 +189,11 @@ function useTerminal(containerRef, options = {}) {
107
189
  const o = optsRef.current;
108
190
  const term = new Terminal$1({
109
191
  theme: o.theme ?? fancyDarkTheme,
110
- rows: o.rows,
111
- cols: o.cols,
192
+ // Only pass rows/cols when explicitly set. xterm validates these and
193
+ // console-errors "rows/cols must be numeric" if handed `undefined`, rather
194
+ // than falling back to its 80×24 default. Omit the keys instead.
195
+ ...typeof o.rows === "number" ? { rows: o.rows } : {},
196
+ ...typeof o.cols === "number" ? { cols: o.cols } : {},
112
197
  disableStdin: o.readOnly ?? false,
113
198
  cursorBlink: o.cursorBlink ?? true,
114
199
  cursorStyle: o.cursorStyle ?? "block",
@@ -126,9 +211,29 @@ function useTerminal(containerRef, options = {}) {
126
211
  const resizeSub = term.onResize(
127
212
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
128
213
  );
214
+ term.attachCustomKeyEventHandler((e) => {
215
+ if (optsRef.current.clipboard === false) return true;
216
+ if (shouldCopyEvent(e, term.hasSelection())) {
217
+ void writeClipboardText(term.getSelection());
218
+ return false;
219
+ }
220
+ return true;
221
+ });
222
+ const onPasteEvent = (e) => {
223
+ const o2 = optsRef.current;
224
+ if (o2.clipboard === false) return;
225
+ if (o2.readOnly) {
226
+ e.preventDefault();
227
+ return;
228
+ }
229
+ const payload = readDataTransfer(e.clipboardData);
230
+ if (o2.onPaste?.(payload) === false) e.preventDefault();
231
+ };
232
+ el.addEventListener("paste", onPasteEvent, true);
129
233
  if (o.initialOutput) term.write(o.initialOutput);
130
234
  if (o.fit ?? true) handle.fit();
131
235
  return () => {
236
+ el.removeEventListener("paste", onPasteEvent, true);
132
237
  dataSub.dispose();
133
238
  resizeSub.dispose();
134
239
  term.dispose();
@@ -359,6 +464,122 @@ function ShellSwitcher({
359
464
  );
360
465
  }
361
466
 
467
+ // src/menu-position.ts
468
+ function clampMenuPosition(at, menu, viewport, margin = 8) {
469
+ const maxX = Math.max(margin, viewport.width - menu.width - margin);
470
+ const maxY = Math.max(margin, viewport.height - menu.height - margin);
471
+ return {
472
+ x: Math.min(Math.max(margin, at.x), maxX),
473
+ y: Math.min(Math.max(margin, at.y), maxY)
474
+ };
475
+ }
476
+ function TerminalContextMenu({
477
+ at,
478
+ items,
479
+ ctx,
480
+ onClose
481
+ }) {
482
+ const ref = useRef(null);
483
+ const [pos, setPos] = useState(at);
484
+ useLayoutEffect(() => {
485
+ const el = ref.current;
486
+ if (!el || typeof window === "undefined") return;
487
+ const r = el.getBoundingClientRect();
488
+ setPos(
489
+ clampMenuPosition(
490
+ at,
491
+ { width: r.width, height: r.height },
492
+ { width: window.innerWidth, height: window.innerHeight }
493
+ )
494
+ );
495
+ }, [at]);
496
+ useEffect(() => {
497
+ const onDown = (e) => {
498
+ if (ref.current && !ref.current.contains(e.target)) onClose();
499
+ };
500
+ const onKey = (e) => {
501
+ if (e.key === "Escape") onClose();
502
+ };
503
+ document.addEventListener("mousedown", onDown, true);
504
+ document.addEventListener("keydown", onKey, true);
505
+ return () => {
506
+ document.removeEventListener("mousedown", onDown, true);
507
+ document.removeEventListener("keydown", onKey, true);
508
+ };
509
+ }, [onClose]);
510
+ return /* @__PURE__ */ jsx(
511
+ "div",
512
+ {
513
+ ref,
514
+ "data-fancy-terminal-menu": "",
515
+ role: "menu",
516
+ style: {
517
+ position: "fixed",
518
+ left: pos.x,
519
+ top: pos.y,
520
+ zIndex: 2147483e3,
521
+ minWidth: 176,
522
+ padding: 4,
523
+ background: "#18181b",
524
+ border: "1px solid #3f3f46",
525
+ borderRadius: 8,
526
+ boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
527
+ color: "#e4e4e7",
528
+ font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
529
+ userSelect: "none"
530
+ },
531
+ children: items.map(
532
+ (it) => it.separator ? /* @__PURE__ */ jsx(
533
+ "div",
534
+ {
535
+ role: "separator",
536
+ style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
537
+ },
538
+ it.id
539
+ ) : /* @__PURE__ */ jsxs(
540
+ "button",
541
+ {
542
+ type: "button",
543
+ role: "menuitem",
544
+ "data-menu-item": it.id,
545
+ disabled: it.disabled,
546
+ onClick: () => {
547
+ if (it.disabled) return;
548
+ it.onSelect?.(ctx);
549
+ onClose();
550
+ },
551
+ onMouseEnter: (e) => {
552
+ if (!it.disabled) e.currentTarget.style.background = "#27272a";
553
+ },
554
+ onMouseLeave: (e) => {
555
+ e.currentTarget.style.background = "transparent";
556
+ },
557
+ style: {
558
+ display: "flex",
559
+ alignItems: "center",
560
+ gap: 9,
561
+ width: "100%",
562
+ textAlign: "left",
563
+ padding: "6px 9px",
564
+ border: "none",
565
+ borderRadius: 5,
566
+ background: "transparent",
567
+ color: it.disabled ? "#52525b" : "inherit",
568
+ cursor: it.disabled ? "default" : "pointer",
569
+ font: "inherit"
570
+ },
571
+ children: [
572
+ /* @__PURE__ */ jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
573
+ /* @__PURE__ */ jsx("span", { style: { flex: 1 }, children: it.label })
574
+ ]
575
+ },
576
+ it.id
577
+ )
578
+ )
579
+ }
580
+ );
581
+ }
582
+
362
583
  // src/output-diff.ts
363
584
  function diffOutput(written, next) {
364
585
  if (next === written) return null;
@@ -375,6 +596,28 @@ function decideShellSelect(shells, current, id) {
375
596
  function decideShellSwitch(current, next) {
376
597
  return { changed: current !== next };
377
598
  }
599
+
600
+ // src/context-menu.ts
601
+ function defaultMenuItems(ctx, actions) {
602
+ const items = [
603
+ { id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
604
+ ];
605
+ if (!ctx.readOnly) {
606
+ items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
607
+ }
608
+ items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
609
+ if (!ctx.readOnly) {
610
+ items.push({ id: "sep-default", separator: true });
611
+ items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
612
+ }
613
+ return items;
614
+ }
615
+ function resolveMenuItems(config, ctx, actions) {
616
+ if (config === false) return null;
617
+ const defaults = defaultMenuItems(ctx, actions);
618
+ const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
619
+ return items.length > 0 ? items : null;
620
+ }
378
621
  var Terminal = forwardRef(function Terminal2({
379
622
  output,
380
623
  theme,
@@ -394,6 +637,9 @@ var Terminal = forwardRef(function Terminal2({
394
637
  activeShell,
395
638
  onShellChange,
396
639
  showShellBar = false,
640
+ clipboard,
641
+ onPaste,
642
+ contextMenu,
397
643
  className,
398
644
  style,
399
645
  ...rest
@@ -426,7 +672,9 @@ var Terminal = forwardRef(function Terminal2({
426
672
  // diffing effect below owns the buffer, so don't double-write.
427
673
  initialOutput: output === void 0 ? initialOutput : void 0,
428
674
  onData,
429
- onResize
675
+ onResize,
676
+ clipboard,
677
+ onPaste
430
678
  });
431
679
  useImperativeHandle(
432
680
  ref,
@@ -446,18 +694,46 @@ var Terminal = forwardRef(function Terminal2({
446
694
  handle.write(change.write);
447
695
  written.current = output;
448
696
  }, [output, handle]);
697
+ const [menu, setMenu] = useState(null);
698
+ const openMenu = useCallback(
699
+ (e) => {
700
+ if (contextMenu === false) return;
701
+ e.preventDefault();
702
+ const selection = handle.getSelection();
703
+ const ctx = {
704
+ hasSelection: selection.length > 0,
705
+ selection,
706
+ readOnly: !!readOnly
707
+ };
708
+ const items = resolveMenuItems(contextMenu, ctx, {
709
+ copy: () => void handle.copySelection(),
710
+ paste: () => void handle.paste(),
711
+ selectAll: () => handle.selectAll(),
712
+ clear: () => handle.clear()
713
+ });
714
+ if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
715
+ },
716
+ [contextMenu, handle, readOnly]
717
+ );
718
+ const menuNode = menu ? /* @__PURE__ */ jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
449
719
  const surface = /* @__PURE__ */ jsx(
450
720
  "div",
451
721
  {
452
722
  ref: containerRef,
453
723
  "data-fancy-terminal": "",
724
+ onContextMenu: openMenu,
454
725
  "data-readonly": readOnly ? "" : void 0,
455
726
  "data-fancy-terminal-shell": shellId ?? void 0,
456
727
  style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
457
728
  ...showShellBar && shells ? {} : rest
458
729
  }
459
730
  );
460
- if (!showShellBar || !shells) return surface;
731
+ if (!showShellBar || !shells) {
732
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
733
+ surface,
734
+ menuNode
735
+ ] });
736
+ }
461
737
  return /* @__PURE__ */ jsxs(
462
738
  "div",
463
739
  {
@@ -488,7 +764,8 @@ var Terminal = forwardRef(function Terminal2({
488
764
  )
489
765
  }
490
766
  ),
491
- surface
767
+ surface,
768
+ menuNode
492
769
  ]
493
770
  }
494
771
  );
@@ -537,6 +814,6 @@ function useTerminalSession(options) {
537
814
  return { output, sendData, append, clear, shell, switchShell };
538
815
  }
539
816
 
540
- export { BUILTIN_SHELLS, ShellSwitcher, Terminal, fancyDarkTheme, resolveShell, useTerminal, useTerminalFit, useTerminalSession };
817
+ export { BUILTIN_SHELLS, ShellSwitcher, Terminal, TerminalContextMenu, clampMenuPosition, defaultMenuItems, fancyDarkTheme, isImageFile, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
541
818
  //# sourceMappingURL=index.js.map
542
819
  //# sourceMappingURL=index.js.map