@particle-academy/fancy-term 0.2.2 → 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/README.md CHANGED
@@ -77,6 +77,9 @@ 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` | enable the copy chord + paste interceptor (default `true`) |
81
+ | `onPaste` | `(payload) => void \| boolean` | every paste; receives `{ text, files, images }` — handle pasted **images** here. Return `false` to suppress the native text paste |
82
+ | `contextMenu` | `false \| Item[] \| (ctx, defaults) => Item[]` | the right-click selection menu (see [Clipboard & context menu](#clipboard--context-menu)) |
80
83
 
81
84
  The ref exposes a `TerminalHandle`:
82
85
 
@@ -85,11 +88,56 @@ const term = useRef<TerminalHandle>(null);
85
88
  // term.current.write / writeln / clear / reset / fit / focus
86
89
  // term.current.getBuffer() → the visible buffer as text (what an agent "sees")
87
90
  // term.current.getSelection() → current selection
91
+ // term.current.copySelection() → copy the selection to the system clipboard (Promise<boolean>)
92
+ // term.current.paste(text?) → paste text (or the system clipboard) into the terminal
93
+ // term.current.selectAll() / clearSelection()
88
94
  // term.current.setShell("pwsh") → switch the active shell (fires onShellChange)
89
95
  // term.current.getShell() → the active shell id
90
96
  // term.current.xterm → the raw xterm.js instance (escape hatch)
91
97
  ```
92
98
 
99
+ ## Clipboard & context menu
100
+
101
+ **Copy** — select text, then **Ctrl+Shift+C** (Windows/Linux) or **Cmd+C** (macOS,
102
+ with a selection) copies to the system clipboard. Plain **Ctrl+C** is never
103
+ intercepted — it stays **SIGINT**.
104
+
105
+ **Paste** — text pastes natively. **Images** can't render in a shell, so a pasted
106
+ image is handed to you via `onPaste` to upload / feed an agent / write a path:
107
+
108
+ ```tsx
109
+ <Terminal
110
+ output={out}
111
+ onData={(d) => backend.send(d)}
112
+ onPaste={({ text, images }) => {
113
+ for (const img of images) upload(img).then((url) => backend.send(url));
114
+ // return false here to also suppress the native text paste
115
+ }}
116
+ />
117
+ ```
118
+
119
+ **Context menu** — right-click shows Copy / Paste / Select all / Clear by default.
120
+ Customize it with the `contextMenu` prop:
121
+
122
+ ```tsx
123
+ // disable it
124
+ <Terminal contextMenu={false} … />
125
+
126
+ // add to the defaults (the function gets the default items)
127
+ <Terminal
128
+ contextMenu={(ctx, defaults) => [
129
+ ...defaults,
130
+ { id: "sep", separator: true },
131
+ { id: "send-agent", label: "Send selection to agent", icon: "🤖",
132
+ disabled: !ctx.hasSelection, onSelect: (c) => agent.send(c.selection) },
133
+ ]}
134
+ …
135
+ />
136
+ ```
137
+
138
+ Each item is `{ id, label?, icon?, disabled?, separator?, onSelect?(ctx) }`. Set
139
+ `clipboard={false}` to turn off the copy chord + image-paste interception entirely.
140
+
93
141
  ## Switching shells
94
142
 
95
143
  fancy-term is a **frontend wrapper** — it never spawns a shell. It owns the
@@ -220,3 +268,9 @@ mutations so every write broadcasts `AgentActivity`, and supports a staged
220
268
  ## License
221
269
 
222
270
  MIT
271
+
272
+ ---
273
+
274
+ ## ⭐ Star Fancy UI
275
+
276
+ 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,6 +52,77 @@ 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
+
117
+ // src/copy-keybinding.ts
118
+ function shouldCopyEvent(e, hasSelection) {
119
+ if (e.type !== "keydown") return false;
120
+ if (e.key !== "c" && e.key !== "C") return false;
121
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
122
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
123
+ return false;
124
+ }
125
+
55
126
  // src/hooks/use-terminal.ts
56
127
  var DEFAULT_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
57
128
  function readBuffer(term) {
@@ -94,6 +165,17 @@ function useTerminal(containerRef, options = {}) {
94
165
  focus: () => xtermRef.current?.focus(),
95
166
  getBuffer: () => readBuffer(xtermRef.current),
96
167
  getSelection: () => xtermRef.current?.getSelection() ?? "",
168
+ copySelection: async () => {
169
+ const sel = xtermRef.current?.getSelection() ?? "";
170
+ if (!sel) return false;
171
+ return writeClipboardText(sel);
172
+ },
173
+ paste: async (text) => {
174
+ const data = text ?? await readClipboardText();
175
+ if (data) xtermRef.current?.paste(data);
176
+ },
177
+ selectAll: () => xtermRef.current?.selectAll(),
178
+ clearSelection: () => xtermRef.current?.clearSelection(),
97
179
  // Shell selection is owned by the <Terminal> component layer (it needs the
98
180
  // `shells` list + onShellChange). The headless engine is shell-agnostic, so
99
181
  // these are no-ops here and get overridden when <Terminal> composes them.
@@ -131,9 +213,29 @@ function useTerminal(containerRef, options = {}) {
131
213
  const resizeSub = term.onResize(
132
214
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
133
215
  );
216
+ term.attachCustomKeyEventHandler((e) => {
217
+ if (optsRef.current.clipboard === false) return true;
218
+ if (shouldCopyEvent(e, term.hasSelection())) {
219
+ void writeClipboardText(term.getSelection());
220
+ return false;
221
+ }
222
+ return true;
223
+ });
224
+ const onPasteEvent = (e) => {
225
+ const o2 = optsRef.current;
226
+ if (o2.clipboard === false) return;
227
+ if (o2.readOnly) {
228
+ e.preventDefault();
229
+ return;
230
+ }
231
+ const payload = readDataTransfer(e.clipboardData);
232
+ if (o2.onPaste?.(payload) === false) e.preventDefault();
233
+ };
234
+ el.addEventListener("paste", onPasteEvent, true);
134
235
  if (o.initialOutput) term.write(o.initialOutput);
135
236
  if (o.fit ?? true) handle.fit();
136
237
  return () => {
238
+ el.removeEventListener("paste", onPasteEvent, true);
137
239
  dataSub.dispose();
138
240
  resizeSub.dispose();
139
241
  term.dispose();
@@ -364,6 +466,122 @@ function ShellSwitcher({
364
466
  );
365
467
  }
366
468
 
469
+ // src/menu-position.ts
470
+ function clampMenuPosition(at, menu, viewport, margin = 8) {
471
+ const maxX = Math.max(margin, viewport.width - menu.width - margin);
472
+ const maxY = Math.max(margin, viewport.height - menu.height - margin);
473
+ return {
474
+ x: Math.min(Math.max(margin, at.x), maxX),
475
+ y: Math.min(Math.max(margin, at.y), maxY)
476
+ };
477
+ }
478
+ function TerminalContextMenu({
479
+ at,
480
+ items,
481
+ ctx,
482
+ onClose
483
+ }) {
484
+ const ref = react.useRef(null);
485
+ const [pos, setPos] = react.useState(at);
486
+ react.useLayoutEffect(() => {
487
+ const el = ref.current;
488
+ if (!el || typeof window === "undefined") return;
489
+ const r = el.getBoundingClientRect();
490
+ setPos(
491
+ clampMenuPosition(
492
+ at,
493
+ { width: r.width, height: r.height },
494
+ { width: window.innerWidth, height: window.innerHeight }
495
+ )
496
+ );
497
+ }, [at]);
498
+ react.useEffect(() => {
499
+ const onDown = (e) => {
500
+ if (ref.current && !ref.current.contains(e.target)) onClose();
501
+ };
502
+ const onKey = (e) => {
503
+ if (e.key === "Escape") onClose();
504
+ };
505
+ document.addEventListener("mousedown", onDown, true);
506
+ document.addEventListener("keydown", onKey, true);
507
+ return () => {
508
+ document.removeEventListener("mousedown", onDown, true);
509
+ document.removeEventListener("keydown", onKey, true);
510
+ };
511
+ }, [onClose]);
512
+ return /* @__PURE__ */ jsxRuntime.jsx(
513
+ "div",
514
+ {
515
+ ref,
516
+ "data-fancy-terminal-menu": "",
517
+ role: "menu",
518
+ style: {
519
+ position: "fixed",
520
+ left: pos.x,
521
+ top: pos.y,
522
+ zIndex: 2147483e3,
523
+ minWidth: 176,
524
+ padding: 4,
525
+ background: "#18181b",
526
+ border: "1px solid #3f3f46",
527
+ borderRadius: 8,
528
+ boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
529
+ color: "#e4e4e7",
530
+ font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
531
+ userSelect: "none"
532
+ },
533
+ children: items.map(
534
+ (it) => it.separator ? /* @__PURE__ */ jsxRuntime.jsx(
535
+ "div",
536
+ {
537
+ role: "separator",
538
+ style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
539
+ },
540
+ it.id
541
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(
542
+ "button",
543
+ {
544
+ type: "button",
545
+ role: "menuitem",
546
+ "data-menu-item": it.id,
547
+ disabled: it.disabled,
548
+ onClick: () => {
549
+ if (it.disabled) return;
550
+ it.onSelect?.(ctx);
551
+ onClose();
552
+ },
553
+ onMouseEnter: (e) => {
554
+ if (!it.disabled) e.currentTarget.style.background = "#27272a";
555
+ },
556
+ onMouseLeave: (e) => {
557
+ e.currentTarget.style.background = "transparent";
558
+ },
559
+ style: {
560
+ display: "flex",
561
+ alignItems: "center",
562
+ gap: 9,
563
+ width: "100%",
564
+ textAlign: "left",
565
+ padding: "6px 9px",
566
+ border: "none",
567
+ borderRadius: 5,
568
+ background: "transparent",
569
+ color: it.disabled ? "#52525b" : "inherit",
570
+ cursor: it.disabled ? "default" : "pointer",
571
+ font: "inherit"
572
+ },
573
+ children: [
574
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
575
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 }, children: it.label })
576
+ ]
577
+ },
578
+ it.id
579
+ )
580
+ )
581
+ }
582
+ );
583
+ }
584
+
367
585
  // src/output-diff.ts
368
586
  function diffOutput(written, next) {
369
587
  if (next === written) return null;
@@ -380,6 +598,28 @@ function decideShellSelect(shells, current, id) {
380
598
  function decideShellSwitch(current, next) {
381
599
  return { changed: current !== next };
382
600
  }
601
+
602
+ // src/context-menu.ts
603
+ function defaultMenuItems(ctx, actions) {
604
+ const items = [
605
+ { id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
606
+ ];
607
+ if (!ctx.readOnly) {
608
+ items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
609
+ }
610
+ items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
611
+ if (!ctx.readOnly) {
612
+ items.push({ id: "sep-default", separator: true });
613
+ items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
614
+ }
615
+ return items;
616
+ }
617
+ function resolveMenuItems(config, ctx, actions) {
618
+ if (config === false) return null;
619
+ const defaults = defaultMenuItems(ctx, actions);
620
+ const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
621
+ return items.length > 0 ? items : null;
622
+ }
383
623
  var Terminal = react.forwardRef(function Terminal2({
384
624
  output,
385
625
  theme,
@@ -399,6 +639,9 @@ var Terminal = react.forwardRef(function Terminal2({
399
639
  activeShell,
400
640
  onShellChange,
401
641
  showShellBar = false,
642
+ clipboard,
643
+ onPaste,
644
+ contextMenu,
402
645
  className,
403
646
  style,
404
647
  ...rest
@@ -431,7 +674,9 @@ var Terminal = react.forwardRef(function Terminal2({
431
674
  // diffing effect below owns the buffer, so don't double-write.
432
675
  initialOutput: output === void 0 ? initialOutput : void 0,
433
676
  onData,
434
- onResize
677
+ onResize,
678
+ clipboard,
679
+ onPaste
435
680
  });
436
681
  react.useImperativeHandle(
437
682
  ref,
@@ -451,18 +696,46 @@ var Terminal = react.forwardRef(function Terminal2({
451
696
  handle.write(change.write);
452
697
  written.current = output;
453
698
  }, [output, handle]);
699
+ const [menu, setMenu] = react.useState(null);
700
+ const openMenu = react.useCallback(
701
+ (e) => {
702
+ if (contextMenu === false) return;
703
+ e.preventDefault();
704
+ const selection = handle.getSelection();
705
+ const ctx = {
706
+ hasSelection: selection.length > 0,
707
+ selection,
708
+ readOnly: !!readOnly
709
+ };
710
+ const items = resolveMenuItems(contextMenu, ctx, {
711
+ copy: () => void handle.copySelection(),
712
+ paste: () => void handle.paste(),
713
+ selectAll: () => handle.selectAll(),
714
+ clear: () => handle.clear()
715
+ });
716
+ if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
717
+ },
718
+ [contextMenu, handle, readOnly]
719
+ );
720
+ const menuNode = menu ? /* @__PURE__ */ jsxRuntime.jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
454
721
  const surface = /* @__PURE__ */ jsxRuntime.jsx(
455
722
  "div",
456
723
  {
457
724
  ref: containerRef,
458
725
  "data-fancy-terminal": "",
726
+ onContextMenu: openMenu,
459
727
  "data-readonly": readOnly ? "" : void 0,
460
728
  "data-fancy-terminal-shell": shellId ?? void 0,
461
729
  style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
462
730
  ...showShellBar && shells ? {} : rest
463
731
  }
464
732
  );
465
- if (!showShellBar || !shells) return surface;
733
+ if (!showShellBar || !shells) {
734
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
735
+ surface,
736
+ menuNode
737
+ ] });
738
+ }
466
739
  return /* @__PURE__ */ jsxRuntime.jsxs(
467
740
  "div",
468
741
  {
@@ -493,7 +766,8 @@ var Terminal = react.forwardRef(function Terminal2({
493
766
  )
494
767
  }
495
768
  ),
496
- surface
769
+ surface,
770
+ menuNode
497
771
  ]
498
772
  }
499
773
  );
@@ -545,8 +819,14 @@ function useTerminalSession(options) {
545
819
  exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
546
820
  exports.ShellSwitcher = ShellSwitcher;
547
821
  exports.Terminal = Terminal;
822
+ exports.TerminalContextMenu = TerminalContextMenu;
823
+ exports.clampMenuPosition = clampMenuPosition;
824
+ exports.defaultMenuItems = defaultMenuItems;
548
825
  exports.fancyDarkTheme = fancyDarkTheme;
826
+ exports.isImageFile = isImageFile;
827
+ exports.resolveMenuItems = resolveMenuItems;
549
828
  exports.resolveShell = resolveShell;
829
+ exports.shouldCopyEvent = shouldCopyEvent;
550
830
  exports.useTerminal = useTerminal;
551
831
  exports.useTerminalFit = useTerminalFit;
552
832
  exports.useTerminalSession = useTerminalSession;