@particle-academy/fancy-term 0.3.0 → 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,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,49 @@ 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
+ ### 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
+ />
186
+ ```
187
+
141
188
  ## Switching shells
142
189
 
143
190
  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(() => {
@@ -640,6 +789,9 @@ var Terminal = react.forwardRef(function Terminal2({
640
789
  onShellChange,
641
790
  showShellBar = false,
642
791
  clipboard,
792
+ osc52,
793
+ copyPaste,
794
+ onReady,
643
795
  onPaste,
644
796
  contextMenu,
645
797
  className,
@@ -676,12 +828,32 @@ var Terminal = react.forwardRef(function Terminal2({
676
828
  onData,
677
829
  onResize,
678
830
  clipboard,
831
+ osc52,
832
+ copyPaste,
833
+ onReady,
679
834
  onPaste
680
835
  });
681
836
  react.useImperativeHandle(
682
837
  ref,
683
838
  () => ({
684
- ...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,
685
857
  setShell: selectShell,
686
858
  getShell: () => shellStateRef.current.shellId
687
859
  }),
@@ -816,14 +988,32 @@ function useTerminalSession(options) {
816
988
  return { output, sendData, append, clear, shell, switchShell };
817
989
  }
818
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
+
819
1000
  exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
820
1001
  exports.ShellSwitcher = ShellSwitcher;
821
1002
  exports.Terminal = Terminal;
822
1003
  exports.TerminalContextMenu = TerminalContextMenu;
823
1004
  exports.clampMenuPosition = clampMenuPosition;
1005
+ exports.copyPasteBehavior = copyPasteBehavior;
1006
+ exports.decodeBase64 = decodeBase64;
824
1007
  exports.defaultMenuItems = defaultMenuItems;
1008
+ exports.encodeBase64 = encodeBase64;
825
1009
  exports.fancyDarkTheme = fancyDarkTheme;
826
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;
827
1017
  exports.resolveMenuItems = resolveMenuItems;
828
1018
  exports.resolveShell = resolveShell;
829
1019
  exports.shouldCopyEvent = shouldCopyEvent;