@particle-academy/fancy-term 0.3.0 → 0.4.1

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,7 +77,10 @@ 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`) |
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`) |
81
84
  | `onPaste` | `(payload) => void \| boolean` | every paste; receives `{ text, files, images }` — handle pasted **images** here. Return `false` to suppress the native text paste |
82
85
  | `contextMenu` | `false \| Item[] \| (ctx, defaults) => Item[]` | the right-click selection menu (see [Clipboard & context menu](#clipboard--context-menu)) |
83
86
 
@@ -93,7 +96,8 @@ const term = useRef<TerminalHandle>(null);
93
96
  // term.current.selectAll() / clearSelection()
94
97
  // term.current.setShell("pwsh") → switch the active shell (fires onShellChange)
95
98
  // term.current.getShell() → the active shell id
96
- // 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
97
101
  ```
98
102
 
99
103
  ## Clipboard & context menu
@@ -138,6 +142,54 @@ Customize it with the `contextMenu` prop:
138
142
  Each item is `{ id, label?, icon?, disabled?, separator?, onSelect?(ctx) }`. Set
139
143
  `clipboard={false}` to turn off the copy chord + image-paste interception entirely.
140
144
 
145
+ `ctx.selection` is **snapshotted before the right-click lands** and the built-in
146
+ Copy writes that snapshot (through the active clipboard provider) — so it still
147
+ copies over a mouse-reporting TUI (Claude Code, tmux, vim with mouse mode),
148
+ where the app's redraw clears xterm's live selection before you can click Copy.
149
+
150
+ ### OSC 52 (terminal-program clipboard)
151
+
152
+ Modern TUIs — Claude Code, tmux, vim/neovim — copy their own selection with an
153
+ **OSC 52** escape (`ESC ] 52`). xterm.js drops these by default, so the app shows
154
+ "copied" but nothing reaches the system clipboard. fancy-term handles OSC 52 out
155
+ of the box (`osc52="copy"`), routing it through the active clipboard provider:
156
+
157
+ ```tsx
158
+ <Terminal osc52="copy" … /> // default — programs may WRITE the clipboard
159
+ <Terminal osc52="both" … /> // also answer read (?) requests — an exfiltration risk, opt in
160
+ <Terminal osc52={false} … /> // disable OSC 52
161
+ ```
162
+
163
+ ### Copy/paste conventions
164
+
165
+ `copyPaste` picks the UX to match your platform expectations:
166
+
167
+ ```tsx
168
+ <Terminal copyPaste="winmac" … /> // Ctrl/Cmd+C copies selection, Ctrl/Cmd+V pastes
169
+ <Terminal copyPaste="linux" … /> // highlight-to-copy + middle-click paste
170
+ <Terminal copyPaste="contextmenu" … /> // menu only (+ Ctrl+Shift+C)
171
+ ```
172
+
173
+ Omit it for the historical default (Ctrl+Shift+C + Cmd+C-with-selection).
174
+ **Ctrl+Shift+C always copies** and plain **Ctrl+C stays SIGINT** (unless `winmac`
175
+ + a selection) in every mode.
176
+
177
+ ### Injectable clipboard (Electron)
178
+
179
+ In a sandboxed Electron renderer `navigator.clipboard` silently no-ops. Supply a
180
+ **provider** so every copy/paste path — the chord, the menu, OSC 52,
181
+ `handle.copySelection` / `handle.paste` — bridges to your main-process clipboard:
182
+
183
+ ```tsx
184
+ <Terminal
185
+ clipboard={{
186
+ writeText: (text) => window.electron.clipboard.write(text), // → IPC
187
+ readText: () => window.electron.clipboard.read(),
188
+ }}
189
+ osc52="copy"
190
+ />
191
+ ```
192
+
141
193
  ## Switching shells
142
194
 
143
195
  fancy-term is a **frontend wrapper** — it never spawns a shell. It owns the
package/dist/index.cjs CHANGED
@@ -113,18 +113,120 @@ async function readClipboardText() {
113
113
  }
114
114
  return "";
115
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
+ }
116
140
 
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;
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
+ });
124
219
  }
125
220
 
126
221
  // src/hooks/use-terminal.ts
127
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
+ }
128
230
  function readBuffer(term) {
129
231
  if (!term) return "";
130
232
  const buf = term.buffer.active;
@@ -140,12 +242,16 @@ function useTerminal(containerRef, options = {}) {
140
242
  const fitRef = react.useRef(null);
141
243
  const optsRef = react.useRef(options);
142
244
  optsRef.current = options;
245
+ const readyRef = react.useRef(makeDeferred());
143
246
  const handleRef = react.useRef(null);
144
247
  if (handleRef.current === null) {
145
248
  handleRef.current = {
146
249
  get xterm() {
147
250
  return xtermRef.current;
148
251
  },
252
+ get ready() {
253
+ return readyRef.current.promise;
254
+ },
149
255
  write: (d) => xtermRef.current?.write(d),
150
256
  writeln: (d) => xtermRef.current?.writeln(d),
151
257
  clear: () => xtermRef.current?.clear(),
@@ -168,10 +274,10 @@ function useTerminal(containerRef, options = {}) {
168
274
  copySelection: async () => {
169
275
  const sel = xtermRef.current?.getSelection() ?? "";
170
276
  if (!sel) return false;
171
- return writeClipboardText(sel);
277
+ return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
172
278
  },
173
279
  paste: async (text) => {
174
- const data = text ?? await readClipboardText();
280
+ const data = text ?? await providerRead(resolveClipboard(optsRef.current.clipboard).provider);
175
281
  if (data) xtermRef.current?.paste(data);
176
282
  },
177
283
  selectAll: () => xtermRef.current?.selectAll(),
@@ -213,17 +319,54 @@ function useTerminal(containerRef, options = {}) {
213
319
  const resizeSub = term.onResize(
214
320
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
215
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
+ };
216
328
  term.attachCustomKeyEventHandler((e) => {
217
- if (optsRef.current.clipboard === false) return true;
218
- if (shouldCopyEvent(e, term.hasSelection())) {
219
- void writeClipboardText(term.getSelection());
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();
220
339
  return false;
221
340
  }
222
341
  return true;
223
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);
224
367
  const onPasteEvent = (e) => {
225
368
  const o2 = optsRef.current;
226
- if (o2.clipboard === false) return;
369
+ if (resolveClipboard(o2.clipboard).enabled === false) return;
227
370
  if (o2.readOnly) {
228
371
  e.preventDefault();
229
372
  return;
@@ -234,13 +377,19 @@ function useTerminal(containerRef, options = {}) {
234
377
  el.addEventListener("paste", onPasteEvent, true);
235
378
  if (o.initialOutput) term.write(o.initialOutput);
236
379
  if (o.fit ?? true) handle.fit();
380
+ optsRef.current.onReady?.(term);
381
+ readyRef.current.resolve(term);
237
382
  return () => {
238
383
  el.removeEventListener("paste", onPasteEvent, true);
384
+ el.removeEventListener("mousedown", onMouseDown);
385
+ oscDisposable?.dispose();
386
+ selectionSub.dispose();
239
387
  dataSub.dispose();
240
388
  resizeSub.dispose();
241
389
  term.dispose();
242
390
  xtermRef.current = null;
243
391
  fitRef.current = null;
392
+ readyRef.current = makeDeferred();
244
393
  };
245
394
  }, [containerRef]);
246
395
  react.useEffect(() => {
@@ -620,6 +769,12 @@ function resolveMenuItems(config, ctx, actions) {
620
769
  const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
621
770
  return items.length > 0 ? items : null;
622
771
  }
772
+
773
+ // src/selection-snapshot.ts
774
+ function nextSelectionSnapshot(prev, event, liveSelection) {
775
+ if (event.type === "mousedown" && event.button === 0) return "";
776
+ return liveSelection || prev;
777
+ }
623
778
  var Terminal = react.forwardRef(function Terminal2({
624
779
  output,
625
780
  theme,
@@ -640,6 +795,9 @@ var Terminal = react.forwardRef(function Terminal2({
640
795
  onShellChange,
641
796
  showShellBar = false,
642
797
  clipboard,
798
+ osc52,
799
+ copyPaste,
800
+ onReady,
643
801
  onPaste,
644
802
  contextMenu,
645
803
  className,
@@ -676,12 +834,32 @@ var Terminal = react.forwardRef(function Terminal2({
676
834
  onData,
677
835
  onResize,
678
836
  clipboard,
837
+ osc52,
838
+ copyPaste,
839
+ onReady,
679
840
  onPaste
680
841
  });
681
842
  react.useImperativeHandle(
682
843
  ref,
683
844
  () => ({
684
- ...handle,
845
+ get xterm() {
846
+ return handle.xterm;
847
+ },
848
+ get ready() {
849
+ return handle.ready;
850
+ },
851
+ write: handle.write,
852
+ writeln: handle.writeln,
853
+ clear: handle.clear,
854
+ reset: handle.reset,
855
+ fit: handle.fit,
856
+ focus: handle.focus,
857
+ getBuffer: handle.getBuffer,
858
+ getSelection: handle.getSelection,
859
+ copySelection: handle.copySelection,
860
+ paste: handle.paste,
861
+ selectAll: handle.selectAll,
862
+ clearSelection: handle.clearSelection,
685
863
  setShell: selectShell,
686
864
  getShell: () => shellStateRef.current.shellId
687
865
  }),
@@ -697,25 +875,42 @@ var Terminal = react.forwardRef(function Terminal2({
697
875
  written.current = output;
698
876
  }, [output, handle]);
699
877
  const [menu, setMenu] = react.useState(null);
878
+ const selectionSnapshot = react.useRef("");
879
+ const trackSelection = react.useCallback(
880
+ (e) => {
881
+ selectionSnapshot.current = nextSelectionSnapshot(
882
+ selectionSnapshot.current,
883
+ { type: e.type, button: e.button },
884
+ handle.getSelection()
885
+ );
886
+ },
887
+ [handle]
888
+ );
700
889
  const openMenu = react.useCallback(
701
890
  (e) => {
702
891
  if (contextMenu === false) return;
703
892
  e.preventDefault();
704
- const selection = handle.getSelection();
893
+ const selection = handle.getSelection() || selectionSnapshot.current;
705
894
  const ctx = {
706
895
  hasSelection: selection.length > 0,
707
896
  selection,
708
897
  readOnly: !!readOnly
709
898
  };
710
899
  const items = resolveMenuItems(contextMenu, ctx, {
711
- copy: () => void handle.copySelection(),
900
+ // Copy writes the SNAPSHOTTED selection through the active clipboard
901
+ // provider — never a click-time re-read, which copies "" over a TUI
902
+ // (falling back to the live selection only when the snapshot is empty).
903
+ copy: (c) => {
904
+ const text = c.selection || handle.getSelection();
905
+ if (text) void providerWrite(resolveClipboard(clipboard).provider, text);
906
+ },
712
907
  paste: () => void handle.paste(),
713
908
  selectAll: () => handle.selectAll(),
714
909
  clear: () => handle.clear()
715
910
  });
716
911
  if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
717
912
  },
718
- [contextMenu, handle, readOnly]
913
+ [contextMenu, handle, readOnly, clipboard]
719
914
  );
720
915
  const menuNode = menu ? /* @__PURE__ */ jsxRuntime.jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
721
916
  const surface = /* @__PURE__ */ jsxRuntime.jsx(
@@ -723,6 +918,8 @@ var Terminal = react.forwardRef(function Terminal2({
723
918
  {
724
919
  ref: containerRef,
725
920
  "data-fancy-terminal": "",
921
+ onMouseDownCapture: trackSelection,
922
+ onMouseUpCapture: trackSelection,
726
923
  onContextMenu: openMenu,
727
924
  "data-readonly": readOnly ? "" : void 0,
728
925
  "data-fancy-terminal-shell": shellId ?? void 0,
@@ -816,14 +1013,33 @@ function useTerminalSession(options) {
816
1013
  return { output, sendData, append, clear, shell, switchShell };
817
1014
  }
818
1015
 
1016
+ // src/copy-keybinding.ts
1017
+ function shouldCopyEvent(e, hasSelection) {
1018
+ if (e.type !== "keydown") return false;
1019
+ if (e.key !== "c" && e.key !== "C") return false;
1020
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
1021
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
1022
+ return false;
1023
+ }
1024
+
819
1025
  exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
820
1026
  exports.ShellSwitcher = ShellSwitcher;
821
1027
  exports.Terminal = Terminal;
822
1028
  exports.TerminalContextMenu = TerminalContextMenu;
823
1029
  exports.clampMenuPosition = clampMenuPosition;
1030
+ exports.copyPasteBehavior = copyPasteBehavior;
1031
+ exports.decodeBase64 = decodeBase64;
824
1032
  exports.defaultMenuItems = defaultMenuItems;
1033
+ exports.encodeBase64 = encodeBase64;
825
1034
  exports.fancyDarkTheme = fancyDarkTheme;
826
1035
  exports.isImageFile = isImageFile;
1036
+ exports.navigatorClipboard = navigatorClipboard;
1037
+ exports.nextSelectionSnapshot = nextSelectionSnapshot;
1038
+ exports.osc52Response = osc52Response;
1039
+ exports.parseOsc52 = parseOsc52;
1040
+ exports.registerOsc52 = registerOsc52;
1041
+ exports.resolveClipboard = resolveClipboard;
1042
+ exports.resolveKeyAction = resolveKeyAction;
827
1043
  exports.resolveMenuItems = resolveMenuItems;
828
1044
  exports.resolveShell = resolveShell;
829
1045
  exports.shouldCopyEvent = shouldCopyEvent;