@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 +49 -2
- package/dist/index.cjs +204 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -6
- package/dist/index.d.ts +142 -6
- package/dist/index.js +196 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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` |
|
|
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-
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
277
|
+
return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
|
|
172
278
|
},
|
|
173
279
|
paste: async (text) => {
|
|
174
|
-
const data = text ?? await
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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;
|