@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/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.
@@ -109,8 +191,11 @@ function useTerminal(containerRef, options = {}) {
109
191
  const o = optsRef.current;
110
192
  const term = new xterm.Terminal({
111
193
  theme: o.theme ?? fancyDarkTheme,
112
- rows: o.rows,
113
- cols: o.cols,
194
+ // Only pass rows/cols when explicitly set. xterm validates these and
195
+ // console-errors "rows/cols must be numeric" if handed `undefined`, rather
196
+ // than falling back to its 80×24 default. Omit the keys instead.
197
+ ...typeof o.rows === "number" ? { rows: o.rows } : {},
198
+ ...typeof o.cols === "number" ? { cols: o.cols } : {},
114
199
  disableStdin: o.readOnly ?? false,
115
200
  cursorBlink: o.cursorBlink ?? true,
116
201
  cursorStyle: o.cursorStyle ?? "block",
@@ -128,9 +213,29 @@ function useTerminal(containerRef, options = {}) {
128
213
  const resizeSub = term.onResize(
129
214
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
130
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);
131
235
  if (o.initialOutput) term.write(o.initialOutput);
132
236
  if (o.fit ?? true) handle.fit();
133
237
  return () => {
238
+ el.removeEventListener("paste", onPasteEvent, true);
134
239
  dataSub.dispose();
135
240
  resizeSub.dispose();
136
241
  term.dispose();
@@ -361,6 +466,122 @@ function ShellSwitcher({
361
466
  );
362
467
  }
363
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
+
364
585
  // src/output-diff.ts
365
586
  function diffOutput(written, next) {
366
587
  if (next === written) return null;
@@ -377,6 +598,28 @@ function decideShellSelect(shells, current, id) {
377
598
  function decideShellSwitch(current, next) {
378
599
  return { changed: current !== next };
379
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
+ }
380
623
  var Terminal = react.forwardRef(function Terminal2({
381
624
  output,
382
625
  theme,
@@ -396,6 +639,9 @@ var Terminal = react.forwardRef(function Terminal2({
396
639
  activeShell,
397
640
  onShellChange,
398
641
  showShellBar = false,
642
+ clipboard,
643
+ onPaste,
644
+ contextMenu,
399
645
  className,
400
646
  style,
401
647
  ...rest
@@ -428,7 +674,9 @@ var Terminal = react.forwardRef(function Terminal2({
428
674
  // diffing effect below owns the buffer, so don't double-write.
429
675
  initialOutput: output === void 0 ? initialOutput : void 0,
430
676
  onData,
431
- onResize
677
+ onResize,
678
+ clipboard,
679
+ onPaste
432
680
  });
433
681
  react.useImperativeHandle(
434
682
  ref,
@@ -448,18 +696,46 @@ var Terminal = react.forwardRef(function Terminal2({
448
696
  handle.write(change.write);
449
697
  written.current = output;
450
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;
451
721
  const surface = /* @__PURE__ */ jsxRuntime.jsx(
452
722
  "div",
453
723
  {
454
724
  ref: containerRef,
455
725
  "data-fancy-terminal": "",
726
+ onContextMenu: openMenu,
456
727
  "data-readonly": readOnly ? "" : void 0,
457
728
  "data-fancy-terminal-shell": shellId ?? void 0,
458
729
  style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
459
730
  ...showShellBar && shells ? {} : rest
460
731
  }
461
732
  );
462
- if (!showShellBar || !shells) return surface;
733
+ if (!showShellBar || !shells) {
734
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
735
+ surface,
736
+ menuNode
737
+ ] });
738
+ }
463
739
  return /* @__PURE__ */ jsxRuntime.jsxs(
464
740
  "div",
465
741
  {
@@ -490,7 +766,8 @@ var Terminal = react.forwardRef(function Terminal2({
490
766
  )
491
767
  }
492
768
  ),
493
- surface
769
+ surface,
770
+ menuNode
494
771
  ]
495
772
  }
496
773
  );
@@ -542,8 +819,14 @@ function useTerminalSession(options) {
542
819
  exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
543
820
  exports.ShellSwitcher = ShellSwitcher;
544
821
  exports.Terminal = Terminal;
822
+ exports.TerminalContextMenu = TerminalContextMenu;
823
+ exports.clampMenuPosition = clampMenuPosition;
824
+ exports.defaultMenuItems = defaultMenuItems;
545
825
  exports.fancyDarkTheme = fancyDarkTheme;
826
+ exports.isImageFile = isImageFile;
827
+ exports.resolveMenuItems = resolveMenuItems;
546
828
  exports.resolveShell = resolveShell;
829
+ exports.shouldCopyEvent = shouldCopyEvent;
547
830
  exports.useTerminal = useTerminal;
548
831
  exports.useTerminalFit = useTerminalFit;
549
832
  exports.useTerminalSession = useTerminalSession;