@particle-academy/fancy-term 0.2.2 → 0.4.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
@@ -77,6 +77,12 @@ fancy-query's `useFancyStream`.
77
77
  | `activeShell` | `string` | controlled selected shell id (omit for uncontrolled) |
78
78
  | `onShellChange` | `(id, profile) => void` | fired when the user / `setShell` switches |
79
79
  | `showShellBar` | `boolean` | render the `<ShellSwitcher>` toolbar above the surface (default `false`) |
80
+ | `clipboard` | `boolean \| { writeText, readText }` | clipboard wiring: `true`/omit (navigator), `false` (off), or an **injectable provider** for sandboxed Electron hosts (default `true`) |
81
+ | `osc52` | `"copy" \| "read" \| "both" \| false` | OSC 52 policy — let terminal programs (Claude Code, tmux, vim) set/read the clipboard (default `"copy"`, write-only) |
82
+ | `copyPaste` | `"contextmenu" \| "linux" \| "winmac"` | copy/paste convention (see below); omit for the default chord |
83
+ | `onReady` | `(xterm) => void` | called once xterm is opened + measured (twin of `handle.ready`) |
84
+ | `onPaste` | `(payload) => void \| boolean` | every paste; receives `{ text, files, images }` — handle pasted **images** here. Return `false` to suppress the native text paste |
85
+ | `contextMenu` | `false \| Item[] \| (ctx, defaults) => Item[]` | the right-click selection menu (see [Clipboard & context menu](#clipboard--context-menu)) |
80
86
 
81
87
  The ref exposes a `TerminalHandle`:
82
88
 
@@ -85,9 +91,98 @@ const term = useRef<TerminalHandle>(null);
85
91
  // term.current.write / writeln / clear / reset / fit / focus
86
92
  // term.current.getBuffer() → the visible buffer as text (what an agent "sees")
87
93
  // term.current.getSelection() → current selection
94
+ // term.current.copySelection() → copy the selection to the system clipboard (Promise<boolean>)
95
+ // term.current.paste(text?) → paste text (or the system clipboard) into the terminal
96
+ // term.current.selectAll() / clearSelection()
88
97
  // term.current.setShell("pwsh") → switch the active shell (fires onShellChange)
89
98
  // term.current.getShell() → the active shell id
90
- // term.current.xterm → the raw xterm.js instance (escape hatch)
99
+ // term.current.xterm → the raw xterm.js instance (escape hatch; null before mount)
100
+ // term.current.ready → Promise<XTerm>, resolves once opened + measured
101
+ ```
102
+
103
+ ## Clipboard & context menu
104
+
105
+ **Copy** — select text, then **Ctrl+Shift+C** (Windows/Linux) or **Cmd+C** (macOS,
106
+ with a selection) copies to the system clipboard. Plain **Ctrl+C** is never
107
+ intercepted — it stays **SIGINT**.
108
+
109
+ **Paste** — text pastes natively. **Images** can't render in a shell, so a pasted
110
+ image is handed to you via `onPaste` to upload / feed an agent / write a path:
111
+
112
+ ```tsx
113
+ <Terminal
114
+ output={out}
115
+ onData={(d) => backend.send(d)}
116
+ onPaste={({ text, images }) => {
117
+ for (const img of images) upload(img).then((url) => backend.send(url));
118
+ // return false here to also suppress the native text paste
119
+ }}
120
+ />
121
+ ```
122
+
123
+ **Context menu** — right-click shows Copy / Paste / Select all / Clear by default.
124
+ Customize it with the `contextMenu` prop:
125
+
126
+ ```tsx
127
+ // disable it
128
+ <Terminal contextMenu={false} … />
129
+
130
+ // add to the defaults (the function gets the default items)
131
+ <Terminal
132
+ contextMenu={(ctx, defaults) => [
133
+ ...defaults,
134
+ { id: "sep", separator: true },
135
+ { id: "send-agent", label: "Send selection to agent", icon: "🤖",
136
+ disabled: !ctx.hasSelection, onSelect: (c) => agent.send(c.selection) },
137
+ ]}
138
+ …
139
+ />
140
+ ```
141
+
142
+ Each item is `{ id, label?, icon?, disabled?, separator?, onSelect?(ctx) }`. Set
143
+ `clipboard={false}` to turn off the copy chord + image-paste interception entirely.
144
+
145
+ ### OSC 52 (terminal-program clipboard)
146
+
147
+ Modern TUIs — Claude Code, tmux, vim/neovim — copy their own selection with an
148
+ **OSC 52** escape (`ESC ] 52`). xterm.js drops these by default, so the app shows
149
+ "copied" but nothing reaches the system clipboard. fancy-term handles OSC 52 out
150
+ of the box (`osc52="copy"`), routing it through the active clipboard provider:
151
+
152
+ ```tsx
153
+ <Terminal osc52="copy" … /> // default — programs may WRITE the clipboard
154
+ <Terminal osc52="both" … /> // also answer read (?) requests — an exfiltration risk, opt in
155
+ <Terminal osc52={false} … /> // disable OSC 52
156
+ ```
157
+
158
+ ### Copy/paste conventions
159
+
160
+ `copyPaste` picks the UX to match your platform expectations:
161
+
162
+ ```tsx
163
+ <Terminal copyPaste="winmac" … /> // Ctrl/Cmd+C copies selection, Ctrl/Cmd+V pastes
164
+ <Terminal copyPaste="linux" … /> // highlight-to-copy + middle-click paste
165
+ <Terminal copyPaste="contextmenu" … /> // menu only (+ Ctrl+Shift+C)
166
+ ```
167
+
168
+ Omit it for the historical default (Ctrl+Shift+C + Cmd+C-with-selection).
169
+ **Ctrl+Shift+C always copies** and plain **Ctrl+C stays SIGINT** (unless `winmac`
170
+ + a selection) in every mode.
171
+
172
+ ### Injectable clipboard (Electron)
173
+
174
+ In a sandboxed Electron renderer `navigator.clipboard` silently no-ops. Supply a
175
+ **provider** so every copy/paste path — the chord, the menu, OSC 52,
176
+ `handle.copySelection` / `handle.paste` — bridges to your main-process clipboard:
177
+
178
+ ```tsx
179
+ <Terminal
180
+ clipboard={{
181
+ writeText: (text) => window.electron.clipboard.write(text), // → IPC
182
+ readText: () => window.electron.clipboard.read(),
183
+ }}
184
+ osc52="copy"
185
+ />
91
186
  ```
92
187
 
93
188
  ## Switching shells
@@ -220,3 +315,9 @@ mutations so every write broadcasts `AgentActivity`, and supports a staged
220
315
  ## License
221
316
 
222
317
  MIT
318
+
319
+ ---
320
+
321
+ ## ⭐ Star Fancy UI
322
+
323
+ If this package is useful to you, a quick ⭐ on the repo really helps us build a better kit. Thank you!
package/dist/index.cjs CHANGED
@@ -52,8 +52,181 @@ function useTerminalFit(ref, fit, enabled = true) {
52
52
  }, [ref, enabled]);
53
53
  }
54
54
 
55
+ // src/clipboard.ts
56
+ function isImageFile(f) {
57
+ return typeof f.type === "string" && f.type.startsWith("image/");
58
+ }
59
+ function readDataTransfer(dt) {
60
+ const files = [];
61
+ let text = "";
62
+ if (dt) {
63
+ try {
64
+ text = dt.getData("text/plain") ?? "";
65
+ } catch {
66
+ }
67
+ const items = dt.items;
68
+ if (items) {
69
+ for (let i = 0; i < items.length; i++) {
70
+ const it = items[i];
71
+ if (it && it.kind === "file") {
72
+ const f = it.getAsFile();
73
+ if (f) files.push(f);
74
+ }
75
+ }
76
+ }
77
+ if (files.length === 0 && dt.files) {
78
+ for (let i = 0; i < dt.files.length; i++) files.push(dt.files[i]);
79
+ }
80
+ }
81
+ return { text, files, images: files.filter(isImageFile) };
82
+ }
83
+ async function writeClipboardText(text) {
84
+ try {
85
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
86
+ if (nav?.clipboard?.writeText) {
87
+ await nav.clipboard.writeText(text);
88
+ return true;
89
+ }
90
+ } catch {
91
+ }
92
+ try {
93
+ if (typeof document === "undefined") return false;
94
+ const ta = document.createElement("textarea");
95
+ ta.value = text;
96
+ ta.style.position = "fixed";
97
+ ta.style.left = "-9999px";
98
+ ta.style.opacity = "0";
99
+ document.body.appendChild(ta);
100
+ ta.select();
101
+ const ok = document.execCommand("copy");
102
+ document.body.removeChild(ta);
103
+ return ok;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+ async function readClipboardText() {
109
+ try {
110
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
111
+ if (nav?.clipboard?.readText) return await nav.clipboard.readText();
112
+ } catch {
113
+ }
114
+ return "";
115
+ }
116
+ var navigatorClipboard = {
117
+ writeText: (text) => writeClipboardText(text),
118
+ readText: () => readClipboardText()
119
+ };
120
+ function resolveClipboard(clipboard) {
121
+ if (clipboard === false) return { enabled: false, provider: navigatorClipboard };
122
+ if (clipboard && typeof clipboard === "object") return { enabled: true, provider: clipboard };
123
+ return { enabled: true, provider: navigatorClipboard };
124
+ }
125
+ async function providerWrite(provider, text) {
126
+ try {
127
+ await provider.writeText(text);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+ async function providerRead(provider) {
134
+ try {
135
+ return await provider.readText() ?? "";
136
+ } catch {
137
+ return "";
138
+ }
139
+ }
140
+
141
+ // src/copy-paste-mode.ts
142
+ function copyPasteBehavior(mode) {
143
+ switch (mode) {
144
+ case "linux":
145
+ return { selectToCopy: true, middleClickPaste: true, keyCopy: false, keyPaste: false };
146
+ case "winmac":
147
+ return { selectToCopy: false, middleClickPaste: false, keyCopy: true, keyPaste: true };
148
+ case "contextmenu":
149
+ default:
150
+ return { selectToCopy: false, middleClickPaste: false, keyCopy: false, keyPaste: false };
151
+ }
152
+ }
153
+ function resolveKeyAction(e, hasSelection, behavior) {
154
+ if (e.type !== "keydown") return null;
155
+ const key = e.key.toLowerCase();
156
+ if (key === "c") {
157
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return "copy";
158
+ if (e.metaKey && !e.ctrlKey && hasSelection) return "copy";
159
+ if (behavior.keyCopy && (e.ctrlKey || e.metaKey) && !e.shiftKey && hasSelection) return "copy";
160
+ return null;
161
+ }
162
+ if (key === "v" && behavior.keyPaste) {
163
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey) return "paste";
164
+ if (e.ctrlKey && e.shiftKey) return "paste";
165
+ return null;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // src/osc52.ts
171
+ function encodeBase64(text) {
172
+ const bytes = new TextEncoder().encode(text);
173
+ let binary = "";
174
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
175
+ return typeof btoa === "function" ? btoa(binary) : binary;
176
+ }
177
+ function decodeBase64(b64) {
178
+ try {
179
+ const binary = typeof atob === "function" ? atob(b64) : b64;
180
+ const bytes = new Uint8Array(binary.length);
181
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
182
+ return new TextDecoder().decode(bytes);
183
+ } catch {
184
+ return "";
185
+ }
186
+ }
187
+ function parseOsc52(payload) {
188
+ const semi = payload.indexOf(";");
189
+ if (semi < 0) return null;
190
+ const selection = payload.slice(0, semi);
191
+ const pd = payload.slice(semi + 1);
192
+ if (pd === "?") return { selection, kind: "read", data: "" };
193
+ return { selection, kind: "write", data: decodeBase64(pd) };
194
+ }
195
+ function osc52Response(selection, text) {
196
+ return `\x1B]52;${selection};${encodeBase64(text)}\x07`;
197
+ }
198
+ function registerOsc52(term, provider, mode) {
199
+ if (mode() === false) return null;
200
+ return term.parser.registerOscHandler(52, (payload) => {
201
+ const m = mode();
202
+ if (m === false) return true;
203
+ const req = parseOsc52(payload);
204
+ if (!req) return false;
205
+ if (req.kind === "write") {
206
+ if (m === "copy" || m === "both") {
207
+ void Promise.resolve(provider().writeText(req.data)).catch(() => {
208
+ });
209
+ }
210
+ return true;
211
+ }
212
+ if (m === "read" || m === "both") {
213
+ void providerRead(provider()).then((text) => {
214
+ term.write(osc52Response(req.selection, text));
215
+ });
216
+ }
217
+ return true;
218
+ });
219
+ }
220
+
55
221
  // src/hooks/use-terminal.ts
56
222
  var DEFAULT_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
223
+ function makeDeferred() {
224
+ let resolve;
225
+ const promise = new Promise((r) => {
226
+ resolve = r;
227
+ });
228
+ return { promise, resolve };
229
+ }
57
230
  function readBuffer(term) {
58
231
  if (!term) return "";
59
232
  const buf = term.buffer.active;
@@ -69,12 +242,16 @@ function useTerminal(containerRef, options = {}) {
69
242
  const fitRef = react.useRef(null);
70
243
  const optsRef = react.useRef(options);
71
244
  optsRef.current = options;
245
+ const readyRef = react.useRef(makeDeferred());
72
246
  const handleRef = react.useRef(null);
73
247
  if (handleRef.current === null) {
74
248
  handleRef.current = {
75
249
  get xterm() {
76
250
  return xtermRef.current;
77
251
  },
252
+ get ready() {
253
+ return readyRef.current.promise;
254
+ },
78
255
  write: (d) => xtermRef.current?.write(d),
79
256
  writeln: (d) => xtermRef.current?.writeln(d),
80
257
  clear: () => xtermRef.current?.clear(),
@@ -94,6 +271,17 @@ function useTerminal(containerRef, options = {}) {
94
271
  focus: () => xtermRef.current?.focus(),
95
272
  getBuffer: () => readBuffer(xtermRef.current),
96
273
  getSelection: () => xtermRef.current?.getSelection() ?? "",
274
+ copySelection: async () => {
275
+ const sel = xtermRef.current?.getSelection() ?? "";
276
+ if (!sel) return false;
277
+ return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
278
+ },
279
+ paste: async (text) => {
280
+ const data = text ?? await providerRead(resolveClipboard(optsRef.current.clipboard).provider);
281
+ if (data) xtermRef.current?.paste(data);
282
+ },
283
+ selectAll: () => xtermRef.current?.selectAll(),
284
+ clearSelection: () => xtermRef.current?.clearSelection(),
97
285
  // Shell selection is owned by the <Terminal> component layer (it needs the
98
286
  // `shells` list + onShellChange). The headless engine is shell-agnostic, so
99
287
  // these are no-ops here and get overridden when <Terminal> composes them.
@@ -131,14 +319,77 @@ function useTerminal(containerRef, options = {}) {
131
319
  const resizeSub = term.onResize(
132
320
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
133
321
  );
322
+ const pasteFromClipboard = async () => {
323
+ const oo = optsRef.current;
324
+ if (oo.readOnly) return;
325
+ const text = await providerRead(resolveClipboard(oo.clipboard).provider);
326
+ if (text) xtermRef.current?.paste(text);
327
+ };
328
+ term.attachCustomKeyEventHandler((e) => {
329
+ const oo = optsRef.current;
330
+ const clip = resolveClipboard(oo.clipboard);
331
+ if (!clip.enabled) return true;
332
+ const action = resolveKeyAction(e, term.hasSelection(), copyPasteBehavior(oo.copyPaste));
333
+ if (action === "copy") {
334
+ void providerWrite(clip.provider, term.getSelection());
335
+ return false;
336
+ }
337
+ if (action === "paste") {
338
+ void pasteFromClipboard();
339
+ return false;
340
+ }
341
+ return true;
342
+ });
343
+ const oscDisposable = registerOsc52(
344
+ term,
345
+ () => resolveClipboard(optsRef.current.clipboard).provider,
346
+ () => {
347
+ const oo = optsRef.current;
348
+ return resolveClipboard(oo.clipboard).enabled ? oo.osc52 ?? "copy" : false;
349
+ }
350
+ );
351
+ const selectionSub = term.onSelectionChange(() => {
352
+ const oo = optsRef.current;
353
+ const clip = resolveClipboard(oo.clipboard);
354
+ if (clip.enabled && copyPasteBehavior(oo.copyPaste).selectToCopy && term.hasSelection()) {
355
+ void providerWrite(clip.provider, term.getSelection());
356
+ }
357
+ });
358
+ const onMouseDown = (ev) => {
359
+ const oo = optsRef.current;
360
+ if (ev.button !== 1) return;
361
+ if (!resolveClipboard(oo.clipboard).enabled) return;
362
+ if (!copyPasteBehavior(oo.copyPaste).middleClickPaste) return;
363
+ ev.preventDefault();
364
+ void pasteFromClipboard();
365
+ };
366
+ el.addEventListener("mousedown", onMouseDown);
367
+ const onPasteEvent = (e) => {
368
+ const o2 = optsRef.current;
369
+ if (resolveClipboard(o2.clipboard).enabled === false) return;
370
+ if (o2.readOnly) {
371
+ e.preventDefault();
372
+ return;
373
+ }
374
+ const payload = readDataTransfer(e.clipboardData);
375
+ if (o2.onPaste?.(payload) === false) e.preventDefault();
376
+ };
377
+ el.addEventListener("paste", onPasteEvent, true);
134
378
  if (o.initialOutput) term.write(o.initialOutput);
135
379
  if (o.fit ?? true) handle.fit();
380
+ optsRef.current.onReady?.(term);
381
+ readyRef.current.resolve(term);
136
382
  return () => {
383
+ el.removeEventListener("paste", onPasteEvent, true);
384
+ el.removeEventListener("mousedown", onMouseDown);
385
+ oscDisposable?.dispose();
386
+ selectionSub.dispose();
137
387
  dataSub.dispose();
138
388
  resizeSub.dispose();
139
389
  term.dispose();
140
390
  xtermRef.current = null;
141
391
  fitRef.current = null;
392
+ readyRef.current = makeDeferred();
142
393
  };
143
394
  }, [containerRef]);
144
395
  react.useEffect(() => {
@@ -364,6 +615,122 @@ function ShellSwitcher({
364
615
  );
365
616
  }
366
617
 
618
+ // src/menu-position.ts
619
+ function clampMenuPosition(at, menu, viewport, margin = 8) {
620
+ const maxX = Math.max(margin, viewport.width - menu.width - margin);
621
+ const maxY = Math.max(margin, viewport.height - menu.height - margin);
622
+ return {
623
+ x: Math.min(Math.max(margin, at.x), maxX),
624
+ y: Math.min(Math.max(margin, at.y), maxY)
625
+ };
626
+ }
627
+ function TerminalContextMenu({
628
+ at,
629
+ items,
630
+ ctx,
631
+ onClose
632
+ }) {
633
+ const ref = react.useRef(null);
634
+ const [pos, setPos] = react.useState(at);
635
+ react.useLayoutEffect(() => {
636
+ const el = ref.current;
637
+ if (!el || typeof window === "undefined") return;
638
+ const r = el.getBoundingClientRect();
639
+ setPos(
640
+ clampMenuPosition(
641
+ at,
642
+ { width: r.width, height: r.height },
643
+ { width: window.innerWidth, height: window.innerHeight }
644
+ )
645
+ );
646
+ }, [at]);
647
+ react.useEffect(() => {
648
+ const onDown = (e) => {
649
+ if (ref.current && !ref.current.contains(e.target)) onClose();
650
+ };
651
+ const onKey = (e) => {
652
+ if (e.key === "Escape") onClose();
653
+ };
654
+ document.addEventListener("mousedown", onDown, true);
655
+ document.addEventListener("keydown", onKey, true);
656
+ return () => {
657
+ document.removeEventListener("mousedown", onDown, true);
658
+ document.removeEventListener("keydown", onKey, true);
659
+ };
660
+ }, [onClose]);
661
+ return /* @__PURE__ */ jsxRuntime.jsx(
662
+ "div",
663
+ {
664
+ ref,
665
+ "data-fancy-terminal-menu": "",
666
+ role: "menu",
667
+ style: {
668
+ position: "fixed",
669
+ left: pos.x,
670
+ top: pos.y,
671
+ zIndex: 2147483e3,
672
+ minWidth: 176,
673
+ padding: 4,
674
+ background: "#18181b",
675
+ border: "1px solid #3f3f46",
676
+ borderRadius: 8,
677
+ boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
678
+ color: "#e4e4e7",
679
+ font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
680
+ userSelect: "none"
681
+ },
682
+ children: items.map(
683
+ (it) => it.separator ? /* @__PURE__ */ jsxRuntime.jsx(
684
+ "div",
685
+ {
686
+ role: "separator",
687
+ style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
688
+ },
689
+ it.id
690
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(
691
+ "button",
692
+ {
693
+ type: "button",
694
+ role: "menuitem",
695
+ "data-menu-item": it.id,
696
+ disabled: it.disabled,
697
+ onClick: () => {
698
+ if (it.disabled) return;
699
+ it.onSelect?.(ctx);
700
+ onClose();
701
+ },
702
+ onMouseEnter: (e) => {
703
+ if (!it.disabled) e.currentTarget.style.background = "#27272a";
704
+ },
705
+ onMouseLeave: (e) => {
706
+ e.currentTarget.style.background = "transparent";
707
+ },
708
+ style: {
709
+ display: "flex",
710
+ alignItems: "center",
711
+ gap: 9,
712
+ width: "100%",
713
+ textAlign: "left",
714
+ padding: "6px 9px",
715
+ border: "none",
716
+ borderRadius: 5,
717
+ background: "transparent",
718
+ color: it.disabled ? "#52525b" : "inherit",
719
+ cursor: it.disabled ? "default" : "pointer",
720
+ font: "inherit"
721
+ },
722
+ children: [
723
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
724
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 }, children: it.label })
725
+ ]
726
+ },
727
+ it.id
728
+ )
729
+ )
730
+ }
731
+ );
732
+ }
733
+
367
734
  // src/output-diff.ts
368
735
  function diffOutput(written, next) {
369
736
  if (next === written) return null;
@@ -380,6 +747,28 @@ function decideShellSelect(shells, current, id) {
380
747
  function decideShellSwitch(current, next) {
381
748
  return { changed: current !== next };
382
749
  }
750
+
751
+ // src/context-menu.ts
752
+ function defaultMenuItems(ctx, actions) {
753
+ const items = [
754
+ { id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
755
+ ];
756
+ if (!ctx.readOnly) {
757
+ items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
758
+ }
759
+ items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
760
+ if (!ctx.readOnly) {
761
+ items.push({ id: "sep-default", separator: true });
762
+ items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
763
+ }
764
+ return items;
765
+ }
766
+ function resolveMenuItems(config, ctx, actions) {
767
+ if (config === false) return null;
768
+ const defaults = defaultMenuItems(ctx, actions);
769
+ const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
770
+ return items.length > 0 ? items : null;
771
+ }
383
772
  var Terminal = react.forwardRef(function Terminal2({
384
773
  output,
385
774
  theme,
@@ -399,6 +788,12 @@ var Terminal = react.forwardRef(function Terminal2({
399
788
  activeShell,
400
789
  onShellChange,
401
790
  showShellBar = false,
791
+ clipboard,
792
+ osc52,
793
+ copyPaste,
794
+ onReady,
795
+ onPaste,
796
+ contextMenu,
402
797
  className,
403
798
  style,
404
799
  ...rest
@@ -431,12 +826,34 @@ var Terminal = react.forwardRef(function Terminal2({
431
826
  // diffing effect below owns the buffer, so don't double-write.
432
827
  initialOutput: output === void 0 ? initialOutput : void 0,
433
828
  onData,
434
- onResize
829
+ onResize,
830
+ clipboard,
831
+ osc52,
832
+ copyPaste,
833
+ onReady,
834
+ onPaste
435
835
  });
436
836
  react.useImperativeHandle(
437
837
  ref,
438
838
  () => ({
439
- ...handle,
839
+ get xterm() {
840
+ return handle.xterm;
841
+ },
842
+ get ready() {
843
+ return handle.ready;
844
+ },
845
+ write: handle.write,
846
+ writeln: handle.writeln,
847
+ clear: handle.clear,
848
+ reset: handle.reset,
849
+ fit: handle.fit,
850
+ focus: handle.focus,
851
+ getBuffer: handle.getBuffer,
852
+ getSelection: handle.getSelection,
853
+ copySelection: handle.copySelection,
854
+ paste: handle.paste,
855
+ selectAll: handle.selectAll,
856
+ clearSelection: handle.clearSelection,
440
857
  setShell: selectShell,
441
858
  getShell: () => shellStateRef.current.shellId
442
859
  }),
@@ -451,18 +868,46 @@ var Terminal = react.forwardRef(function Terminal2({
451
868
  handle.write(change.write);
452
869
  written.current = output;
453
870
  }, [output, handle]);
871
+ const [menu, setMenu] = react.useState(null);
872
+ const openMenu = react.useCallback(
873
+ (e) => {
874
+ if (contextMenu === false) return;
875
+ e.preventDefault();
876
+ const selection = handle.getSelection();
877
+ const ctx = {
878
+ hasSelection: selection.length > 0,
879
+ selection,
880
+ readOnly: !!readOnly
881
+ };
882
+ const items = resolveMenuItems(contextMenu, ctx, {
883
+ copy: () => void handle.copySelection(),
884
+ paste: () => void handle.paste(),
885
+ selectAll: () => handle.selectAll(),
886
+ clear: () => handle.clear()
887
+ });
888
+ if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
889
+ },
890
+ [contextMenu, handle, readOnly]
891
+ );
892
+ const menuNode = menu ? /* @__PURE__ */ jsxRuntime.jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
454
893
  const surface = /* @__PURE__ */ jsxRuntime.jsx(
455
894
  "div",
456
895
  {
457
896
  ref: containerRef,
458
897
  "data-fancy-terminal": "",
898
+ onContextMenu: openMenu,
459
899
  "data-readonly": readOnly ? "" : void 0,
460
900
  "data-fancy-terminal-shell": shellId ?? void 0,
461
901
  style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
462
902
  ...showShellBar && shells ? {} : rest
463
903
  }
464
904
  );
465
- if (!showShellBar || !shells) return surface;
905
+ if (!showShellBar || !shells) {
906
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
907
+ surface,
908
+ menuNode
909
+ ] });
910
+ }
466
911
  return /* @__PURE__ */ jsxRuntime.jsxs(
467
912
  "div",
468
913
  {
@@ -493,7 +938,8 @@ var Terminal = react.forwardRef(function Terminal2({
493
938
  )
494
939
  }
495
940
  ),
496
- surface
941
+ surface,
942
+ menuNode
497
943
  ]
498
944
  }
499
945
  );
@@ -542,11 +988,35 @@ function useTerminalSession(options) {
542
988
  return { output, sendData, append, clear, shell, switchShell };
543
989
  }
544
990
 
991
+ // src/copy-keybinding.ts
992
+ function shouldCopyEvent(e, hasSelection) {
993
+ if (e.type !== "keydown") return false;
994
+ if (e.key !== "c" && e.key !== "C") return false;
995
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
996
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
997
+ return false;
998
+ }
999
+
545
1000
  exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
546
1001
  exports.ShellSwitcher = ShellSwitcher;
547
1002
  exports.Terminal = Terminal;
1003
+ exports.TerminalContextMenu = TerminalContextMenu;
1004
+ exports.clampMenuPosition = clampMenuPosition;
1005
+ exports.copyPasteBehavior = copyPasteBehavior;
1006
+ exports.decodeBase64 = decodeBase64;
1007
+ exports.defaultMenuItems = defaultMenuItems;
1008
+ exports.encodeBase64 = encodeBase64;
548
1009
  exports.fancyDarkTheme = fancyDarkTheme;
1010
+ exports.isImageFile = isImageFile;
1011
+ exports.navigatorClipboard = navigatorClipboard;
1012
+ exports.osc52Response = osc52Response;
1013
+ exports.parseOsc52 = parseOsc52;
1014
+ exports.registerOsc52 = registerOsc52;
1015
+ exports.resolveClipboard = resolveClipboard;
1016
+ exports.resolveKeyAction = resolveKeyAction;
1017
+ exports.resolveMenuItems = resolveMenuItems;
549
1018
  exports.resolveShell = resolveShell;
1019
+ exports.shouldCopyEvent = shouldCopyEvent;
550
1020
  exports.useTerminal = useTerminal;
551
1021
  exports.useTerminalFit = useTerminalFit;
552
1022
  exports.useTerminalSession = useTerminalSession;