@particle-academy/fancy-term 0.2.2 → 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 +102 -1
- package/dist/index.cjs +474 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +283 -3
- package/dist/index.d.ts +283 -3
- package/dist/index.js +462 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,12 @@ 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 \| { 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`) |
|
|
84
|
+
| `onPaste` | `(payload) => void \| boolean` | every paste; receives `{ text, files, images }` — handle pasted **images** here. Return `false` to suppress the native text paste |
|
|
85
|
+
| `contextMenu` | `false \| Item[] \| (ctx, defaults) => Item[]` | the right-click selection menu (see [Clipboard & context menu](#clipboard--context-menu)) |
|
|
80
86
|
|
|
81
87
|
The ref exposes a `TerminalHandle`:
|
|
82
88
|
|
|
@@ -85,9 +91,98 @@ const term = useRef<TerminalHandle>(null);
|
|
|
85
91
|
// term.current.write / writeln / clear / reset / fit / focus
|
|
86
92
|
// term.current.getBuffer() → the visible buffer as text (what an agent "sees")
|
|
87
93
|
// term.current.getSelection() → current selection
|
|
94
|
+
// term.current.copySelection() → copy the selection to the system clipboard (Promise<boolean>)
|
|
95
|
+
// term.current.paste(text?) → paste text (or the system clipboard) into the terminal
|
|
96
|
+
// term.current.selectAll() / clearSelection()
|
|
88
97
|
// term.current.setShell("pwsh") → switch the active shell (fires onShellChange)
|
|
89
98
|
// term.current.getShell() → the active shell id
|
|
90
|
-
// 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
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Clipboard & context menu
|
|
104
|
+
|
|
105
|
+
**Copy** — select text, then **Ctrl+Shift+C** (Windows/Linux) or **Cmd+C** (macOS,
|
|
106
|
+
with a selection) copies to the system clipboard. Plain **Ctrl+C** is never
|
|
107
|
+
intercepted — it stays **SIGINT**.
|
|
108
|
+
|
|
109
|
+
**Paste** — text pastes natively. **Images** can't render in a shell, so a pasted
|
|
110
|
+
image is handed to you via `onPaste` to upload / feed an agent / write a path:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
<Terminal
|
|
114
|
+
output={out}
|
|
115
|
+
onData={(d) => backend.send(d)}
|
|
116
|
+
onPaste={({ text, images }) => {
|
|
117
|
+
for (const img of images) upload(img).then((url) => backend.send(url));
|
|
118
|
+
// return false here to also suppress the native text paste
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Context menu** — right-click shows Copy / Paste / Select all / Clear by default.
|
|
124
|
+
Customize it with the `contextMenu` prop:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// disable it
|
|
128
|
+
<Terminal contextMenu={false} … />
|
|
129
|
+
|
|
130
|
+
// add to the defaults (the function gets the default items)
|
|
131
|
+
<Terminal
|
|
132
|
+
contextMenu={(ctx, defaults) => [
|
|
133
|
+
...defaults,
|
|
134
|
+
{ id: "sep", separator: true },
|
|
135
|
+
{ id: "send-agent", label: "Send selection to agent", icon: "🤖",
|
|
136
|
+
disabled: !ctx.hasSelection, onSelect: (c) => agent.send(c.selection) },
|
|
137
|
+
]}
|
|
138
|
+
…
|
|
139
|
+
/>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each item is `{ id, label?, icon?, disabled?, separator?, onSelect?(ctx) }`. Set
|
|
143
|
+
`clipboard={false}` to turn off the copy chord + image-paste interception entirely.
|
|
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
|
+
/>
|
|
91
186
|
```
|
|
92
187
|
|
|
93
188
|
## Switching shells
|
|
@@ -220,3 +315,9 @@ mutations so every write broadcasts `AgentActivity`, and supports a staged
|
|
|
220
315
|
## License
|
|
221
316
|
|
|
222
317
|
MIT
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## ⭐ Star Fancy UI
|
|
322
|
+
|
|
323
|
+
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,8 +52,181 @@ 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
|
+
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
|
+
}
|
|
140
|
+
|
|
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
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
55
221
|
// src/hooks/use-terminal.ts
|
|
56
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
|
+
}
|
|
57
230
|
function readBuffer(term) {
|
|
58
231
|
if (!term) return "";
|
|
59
232
|
const buf = term.buffer.active;
|
|
@@ -69,12 +242,16 @@ function useTerminal(containerRef, options = {}) {
|
|
|
69
242
|
const fitRef = react.useRef(null);
|
|
70
243
|
const optsRef = react.useRef(options);
|
|
71
244
|
optsRef.current = options;
|
|
245
|
+
const readyRef = react.useRef(makeDeferred());
|
|
72
246
|
const handleRef = react.useRef(null);
|
|
73
247
|
if (handleRef.current === null) {
|
|
74
248
|
handleRef.current = {
|
|
75
249
|
get xterm() {
|
|
76
250
|
return xtermRef.current;
|
|
77
251
|
},
|
|
252
|
+
get ready() {
|
|
253
|
+
return readyRef.current.promise;
|
|
254
|
+
},
|
|
78
255
|
write: (d) => xtermRef.current?.write(d),
|
|
79
256
|
writeln: (d) => xtermRef.current?.writeln(d),
|
|
80
257
|
clear: () => xtermRef.current?.clear(),
|
|
@@ -94,6 +271,17 @@ function useTerminal(containerRef, options = {}) {
|
|
|
94
271
|
focus: () => xtermRef.current?.focus(),
|
|
95
272
|
getBuffer: () => readBuffer(xtermRef.current),
|
|
96
273
|
getSelection: () => xtermRef.current?.getSelection() ?? "",
|
|
274
|
+
copySelection: async () => {
|
|
275
|
+
const sel = xtermRef.current?.getSelection() ?? "";
|
|
276
|
+
if (!sel) return false;
|
|
277
|
+
return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
|
|
278
|
+
},
|
|
279
|
+
paste: async (text) => {
|
|
280
|
+
const data = text ?? await providerRead(resolveClipboard(optsRef.current.clipboard).provider);
|
|
281
|
+
if (data) xtermRef.current?.paste(data);
|
|
282
|
+
},
|
|
283
|
+
selectAll: () => xtermRef.current?.selectAll(),
|
|
284
|
+
clearSelection: () => xtermRef.current?.clearSelection(),
|
|
97
285
|
// Shell selection is owned by the <Terminal> component layer (it needs the
|
|
98
286
|
// `shells` list + onShellChange). The headless engine is shell-agnostic, so
|
|
99
287
|
// these are no-ops here and get overridden when <Terminal> composes them.
|
|
@@ -131,14 +319,77 @@ function useTerminal(containerRef, options = {}) {
|
|
|
131
319
|
const resizeSub = term.onResize(
|
|
132
320
|
({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
|
|
133
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
|
+
};
|
|
328
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
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();
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
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);
|
|
367
|
+
const onPasteEvent = (e) => {
|
|
368
|
+
const o2 = optsRef.current;
|
|
369
|
+
if (resolveClipboard(o2.clipboard).enabled === false) return;
|
|
370
|
+
if (o2.readOnly) {
|
|
371
|
+
e.preventDefault();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const payload = readDataTransfer(e.clipboardData);
|
|
375
|
+
if (o2.onPaste?.(payload) === false) e.preventDefault();
|
|
376
|
+
};
|
|
377
|
+
el.addEventListener("paste", onPasteEvent, true);
|
|
134
378
|
if (o.initialOutput) term.write(o.initialOutput);
|
|
135
379
|
if (o.fit ?? true) handle.fit();
|
|
380
|
+
optsRef.current.onReady?.(term);
|
|
381
|
+
readyRef.current.resolve(term);
|
|
136
382
|
return () => {
|
|
383
|
+
el.removeEventListener("paste", onPasteEvent, true);
|
|
384
|
+
el.removeEventListener("mousedown", onMouseDown);
|
|
385
|
+
oscDisposable?.dispose();
|
|
386
|
+
selectionSub.dispose();
|
|
137
387
|
dataSub.dispose();
|
|
138
388
|
resizeSub.dispose();
|
|
139
389
|
term.dispose();
|
|
140
390
|
xtermRef.current = null;
|
|
141
391
|
fitRef.current = null;
|
|
392
|
+
readyRef.current = makeDeferred();
|
|
142
393
|
};
|
|
143
394
|
}, [containerRef]);
|
|
144
395
|
react.useEffect(() => {
|
|
@@ -364,6 +615,122 @@ function ShellSwitcher({
|
|
|
364
615
|
);
|
|
365
616
|
}
|
|
366
617
|
|
|
618
|
+
// src/menu-position.ts
|
|
619
|
+
function clampMenuPosition(at, menu, viewport, margin = 8) {
|
|
620
|
+
const maxX = Math.max(margin, viewport.width - menu.width - margin);
|
|
621
|
+
const maxY = Math.max(margin, viewport.height - menu.height - margin);
|
|
622
|
+
return {
|
|
623
|
+
x: Math.min(Math.max(margin, at.x), maxX),
|
|
624
|
+
y: Math.min(Math.max(margin, at.y), maxY)
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function TerminalContextMenu({
|
|
628
|
+
at,
|
|
629
|
+
items,
|
|
630
|
+
ctx,
|
|
631
|
+
onClose
|
|
632
|
+
}) {
|
|
633
|
+
const ref = react.useRef(null);
|
|
634
|
+
const [pos, setPos] = react.useState(at);
|
|
635
|
+
react.useLayoutEffect(() => {
|
|
636
|
+
const el = ref.current;
|
|
637
|
+
if (!el || typeof window === "undefined") return;
|
|
638
|
+
const r = el.getBoundingClientRect();
|
|
639
|
+
setPos(
|
|
640
|
+
clampMenuPosition(
|
|
641
|
+
at,
|
|
642
|
+
{ width: r.width, height: r.height },
|
|
643
|
+
{ width: window.innerWidth, height: window.innerHeight }
|
|
644
|
+
)
|
|
645
|
+
);
|
|
646
|
+
}, [at]);
|
|
647
|
+
react.useEffect(() => {
|
|
648
|
+
const onDown = (e) => {
|
|
649
|
+
if (ref.current && !ref.current.contains(e.target)) onClose();
|
|
650
|
+
};
|
|
651
|
+
const onKey = (e) => {
|
|
652
|
+
if (e.key === "Escape") onClose();
|
|
653
|
+
};
|
|
654
|
+
document.addEventListener("mousedown", onDown, true);
|
|
655
|
+
document.addEventListener("keydown", onKey, true);
|
|
656
|
+
return () => {
|
|
657
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
658
|
+
document.removeEventListener("keydown", onKey, true);
|
|
659
|
+
};
|
|
660
|
+
}, [onClose]);
|
|
661
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
662
|
+
"div",
|
|
663
|
+
{
|
|
664
|
+
ref,
|
|
665
|
+
"data-fancy-terminal-menu": "",
|
|
666
|
+
role: "menu",
|
|
667
|
+
style: {
|
|
668
|
+
position: "fixed",
|
|
669
|
+
left: pos.x,
|
|
670
|
+
top: pos.y,
|
|
671
|
+
zIndex: 2147483e3,
|
|
672
|
+
minWidth: 176,
|
|
673
|
+
padding: 4,
|
|
674
|
+
background: "#18181b",
|
|
675
|
+
border: "1px solid #3f3f46",
|
|
676
|
+
borderRadius: 8,
|
|
677
|
+
boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
|
|
678
|
+
color: "#e4e4e7",
|
|
679
|
+
font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
680
|
+
userSelect: "none"
|
|
681
|
+
},
|
|
682
|
+
children: items.map(
|
|
683
|
+
(it) => it.separator ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
684
|
+
"div",
|
|
685
|
+
{
|
|
686
|
+
role: "separator",
|
|
687
|
+
style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
|
|
688
|
+
},
|
|
689
|
+
it.id
|
|
690
|
+
) : /* @__PURE__ */ jsxRuntime.jsxs(
|
|
691
|
+
"button",
|
|
692
|
+
{
|
|
693
|
+
type: "button",
|
|
694
|
+
role: "menuitem",
|
|
695
|
+
"data-menu-item": it.id,
|
|
696
|
+
disabled: it.disabled,
|
|
697
|
+
onClick: () => {
|
|
698
|
+
if (it.disabled) return;
|
|
699
|
+
it.onSelect?.(ctx);
|
|
700
|
+
onClose();
|
|
701
|
+
},
|
|
702
|
+
onMouseEnter: (e) => {
|
|
703
|
+
if (!it.disabled) e.currentTarget.style.background = "#27272a";
|
|
704
|
+
},
|
|
705
|
+
onMouseLeave: (e) => {
|
|
706
|
+
e.currentTarget.style.background = "transparent";
|
|
707
|
+
},
|
|
708
|
+
style: {
|
|
709
|
+
display: "flex",
|
|
710
|
+
alignItems: "center",
|
|
711
|
+
gap: 9,
|
|
712
|
+
width: "100%",
|
|
713
|
+
textAlign: "left",
|
|
714
|
+
padding: "6px 9px",
|
|
715
|
+
border: "none",
|
|
716
|
+
borderRadius: 5,
|
|
717
|
+
background: "transparent",
|
|
718
|
+
color: it.disabled ? "#52525b" : "inherit",
|
|
719
|
+
cursor: it.disabled ? "default" : "pointer",
|
|
720
|
+
font: "inherit"
|
|
721
|
+
},
|
|
722
|
+
children: [
|
|
723
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
|
|
724
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 }, children: it.label })
|
|
725
|
+
]
|
|
726
|
+
},
|
|
727
|
+
it.id
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
367
734
|
// src/output-diff.ts
|
|
368
735
|
function diffOutput(written, next) {
|
|
369
736
|
if (next === written) return null;
|
|
@@ -380,6 +747,28 @@ function decideShellSelect(shells, current, id) {
|
|
|
380
747
|
function decideShellSwitch(current, next) {
|
|
381
748
|
return { changed: current !== next };
|
|
382
749
|
}
|
|
750
|
+
|
|
751
|
+
// src/context-menu.ts
|
|
752
|
+
function defaultMenuItems(ctx, actions) {
|
|
753
|
+
const items = [
|
|
754
|
+
{ id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
|
|
755
|
+
];
|
|
756
|
+
if (!ctx.readOnly) {
|
|
757
|
+
items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
|
|
758
|
+
}
|
|
759
|
+
items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
|
|
760
|
+
if (!ctx.readOnly) {
|
|
761
|
+
items.push({ id: "sep-default", separator: true });
|
|
762
|
+
items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
|
|
763
|
+
}
|
|
764
|
+
return items;
|
|
765
|
+
}
|
|
766
|
+
function resolveMenuItems(config, ctx, actions) {
|
|
767
|
+
if (config === false) return null;
|
|
768
|
+
const defaults = defaultMenuItems(ctx, actions);
|
|
769
|
+
const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
|
|
770
|
+
return items.length > 0 ? items : null;
|
|
771
|
+
}
|
|
383
772
|
var Terminal = react.forwardRef(function Terminal2({
|
|
384
773
|
output,
|
|
385
774
|
theme,
|
|
@@ -399,6 +788,12 @@ var Terminal = react.forwardRef(function Terminal2({
|
|
|
399
788
|
activeShell,
|
|
400
789
|
onShellChange,
|
|
401
790
|
showShellBar = false,
|
|
791
|
+
clipboard,
|
|
792
|
+
osc52,
|
|
793
|
+
copyPaste,
|
|
794
|
+
onReady,
|
|
795
|
+
onPaste,
|
|
796
|
+
contextMenu,
|
|
402
797
|
className,
|
|
403
798
|
style,
|
|
404
799
|
...rest
|
|
@@ -431,12 +826,34 @@ var Terminal = react.forwardRef(function Terminal2({
|
|
|
431
826
|
// diffing effect below owns the buffer, so don't double-write.
|
|
432
827
|
initialOutput: output === void 0 ? initialOutput : void 0,
|
|
433
828
|
onData,
|
|
434
|
-
onResize
|
|
829
|
+
onResize,
|
|
830
|
+
clipboard,
|
|
831
|
+
osc52,
|
|
832
|
+
copyPaste,
|
|
833
|
+
onReady,
|
|
834
|
+
onPaste
|
|
435
835
|
});
|
|
436
836
|
react.useImperativeHandle(
|
|
437
837
|
ref,
|
|
438
838
|
() => ({
|
|
439
|
-
|
|
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,
|
|
440
857
|
setShell: selectShell,
|
|
441
858
|
getShell: () => shellStateRef.current.shellId
|
|
442
859
|
}),
|
|
@@ -451,18 +868,46 @@ var Terminal = react.forwardRef(function Terminal2({
|
|
|
451
868
|
handle.write(change.write);
|
|
452
869
|
written.current = output;
|
|
453
870
|
}, [output, handle]);
|
|
871
|
+
const [menu, setMenu] = react.useState(null);
|
|
872
|
+
const openMenu = react.useCallback(
|
|
873
|
+
(e) => {
|
|
874
|
+
if (contextMenu === false) return;
|
|
875
|
+
e.preventDefault();
|
|
876
|
+
const selection = handle.getSelection();
|
|
877
|
+
const ctx = {
|
|
878
|
+
hasSelection: selection.length > 0,
|
|
879
|
+
selection,
|
|
880
|
+
readOnly: !!readOnly
|
|
881
|
+
};
|
|
882
|
+
const items = resolveMenuItems(contextMenu, ctx, {
|
|
883
|
+
copy: () => void handle.copySelection(),
|
|
884
|
+
paste: () => void handle.paste(),
|
|
885
|
+
selectAll: () => handle.selectAll(),
|
|
886
|
+
clear: () => handle.clear()
|
|
887
|
+
});
|
|
888
|
+
if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
|
|
889
|
+
},
|
|
890
|
+
[contextMenu, handle, readOnly]
|
|
891
|
+
);
|
|
892
|
+
const menuNode = menu ? /* @__PURE__ */ jsxRuntime.jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
|
|
454
893
|
const surface = /* @__PURE__ */ jsxRuntime.jsx(
|
|
455
894
|
"div",
|
|
456
895
|
{
|
|
457
896
|
ref: containerRef,
|
|
458
897
|
"data-fancy-terminal": "",
|
|
898
|
+
onContextMenu: openMenu,
|
|
459
899
|
"data-readonly": readOnly ? "" : void 0,
|
|
460
900
|
"data-fancy-terminal-shell": shellId ?? void 0,
|
|
461
901
|
style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
|
|
462
902
|
...showShellBar && shells ? {} : rest
|
|
463
903
|
}
|
|
464
904
|
);
|
|
465
|
-
if (!showShellBar || !shells)
|
|
905
|
+
if (!showShellBar || !shells) {
|
|
906
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
907
|
+
surface,
|
|
908
|
+
menuNode
|
|
909
|
+
] });
|
|
910
|
+
}
|
|
466
911
|
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
467
912
|
"div",
|
|
468
913
|
{
|
|
@@ -493,7 +938,8 @@ var Terminal = react.forwardRef(function Terminal2({
|
|
|
493
938
|
)
|
|
494
939
|
}
|
|
495
940
|
),
|
|
496
|
-
surface
|
|
941
|
+
surface,
|
|
942
|
+
menuNode
|
|
497
943
|
]
|
|
498
944
|
}
|
|
499
945
|
);
|
|
@@ -542,11 +988,35 @@ function useTerminalSession(options) {
|
|
|
542
988
|
return { output, sendData, append, clear, shell, switchShell };
|
|
543
989
|
}
|
|
544
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
|
+
|
|
545
1000
|
exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
|
|
546
1001
|
exports.ShellSwitcher = ShellSwitcher;
|
|
547
1002
|
exports.Terminal = Terminal;
|
|
1003
|
+
exports.TerminalContextMenu = TerminalContextMenu;
|
|
1004
|
+
exports.clampMenuPosition = clampMenuPosition;
|
|
1005
|
+
exports.copyPasteBehavior = copyPasteBehavior;
|
|
1006
|
+
exports.decodeBase64 = decodeBase64;
|
|
1007
|
+
exports.defaultMenuItems = defaultMenuItems;
|
|
1008
|
+
exports.encodeBase64 = encodeBase64;
|
|
548
1009
|
exports.fancyDarkTheme = fancyDarkTheme;
|
|
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;
|
|
1017
|
+
exports.resolveMenuItems = resolveMenuItems;
|
|
549
1018
|
exports.resolveShell = resolveShell;
|
|
1019
|
+
exports.shouldCopyEvent = shouldCopyEvent;
|
|
550
1020
|
exports.useTerminal = useTerminal;
|
|
551
1021
|
exports.useTerminalFit = useTerminalFit;
|
|
552
1022
|
exports.useTerminalSession = useTerminalSession;
|