bitpull 0.1.30 → 0.1.32
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/bitpull.cjs +2008 -1585
- package/package.json +1 -1
- package/ui/drop.js +155 -0
- package/ui/ide-terminal.js +20 -2
- package/ui/ide.css +10 -0
- package/ui/ide.html +5 -1
- package/ui/ide.js +238 -4
- package/ui/quick.js +668 -0
package/ui/quick.js
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// Quick commands of the composer, the same in Projects and in the chat, on a computer and on a phone:
|
|
2
|
+
// / at the start: a command or a custom one ("skill"); the text after its name is its arguments
|
|
3
|
+
// ! at the start: a command run on this computer in the project's folder, without the model
|
|
4
|
+
// @ a file of the project (Projects handles it)
|
|
5
|
+
// : an emoji by its short name, :heart: becomes ❤️
|
|
6
|
+
// ? on an empty input: the keyboard shortcuts
|
|
7
|
+
// Every command is in one registry: built-in ones (their action given by each page), custom ones from
|
|
8
|
+
// Markdown files, and later plugins and MCP prompts. Keys come from ~/.bitpull/keybindings.json.
|
|
9
|
+
(() => {
|
|
10
|
+
/** The built-in commands: [name, aliases, category, argument hint, runs at once, hidden]. */
|
|
11
|
+
const CATALOG = [
|
|
12
|
+
["clear", ["reset", "new"], "session", "[name]"],
|
|
13
|
+
["compact", [], "session", "[instructions]"],
|
|
14
|
+
["resume", ["continue"], "session", "[name]", true],
|
|
15
|
+
["rename", [], "session", "[name]"],
|
|
16
|
+
["export", [], "session", "[file]", true],
|
|
17
|
+
["copy", [], "session", "[N]", true],
|
|
18
|
+
["recap", [], "session", ""],
|
|
19
|
+
["btw", [], "session", "<question>", true],
|
|
20
|
+
["rewind", ["checkpoint", "undo"], "session", ""],
|
|
21
|
+
["stop", [], "session", "", true],
|
|
22
|
+
["exit", ["quit"], "session", "", true],
|
|
23
|
+
["add-dir", ["folder"], "session", "<path>", true],
|
|
24
|
+
["cd", [], "session", "<path>", true],
|
|
25
|
+
["init", [], "context", ""],
|
|
26
|
+
["memory", [], "context", "", true],
|
|
27
|
+
["context", [], "context", "", true],
|
|
28
|
+
["model", [], "model", "[model]", true],
|
|
29
|
+
["plan", [], "model", "[description]"],
|
|
30
|
+
["permissions", ["allowed-tools"], "model", "[ask|edits|auto]", true],
|
|
31
|
+
["diff", [], "code", "", true],
|
|
32
|
+
["review", ["code-review"], "code", "[focus]"],
|
|
33
|
+
["security-review", [], "code", ""],
|
|
34
|
+
["simplify", [], "code", "[target]"],
|
|
35
|
+
["verify", [], "code", ""],
|
|
36
|
+
["debug", [], "code", "[description]"],
|
|
37
|
+
["tasks", ["bashes"], "agents", "", true],
|
|
38
|
+
["loop", ["proactive"], "agents", "[interval] [prompt] | stop", true],
|
|
39
|
+
["skills", [], "extensions", "", true],
|
|
40
|
+
["reload-skills", [], "extensions", "", true],
|
|
41
|
+
["mcp", [], "extensions", "", true],
|
|
42
|
+
["config", ["settings"], "config", "", true],
|
|
43
|
+
["theme", [], "config", "", true],
|
|
44
|
+
["keybindings", [], "config", "", true],
|
|
45
|
+
["privacy-settings", [], "config", "", true],
|
|
46
|
+
["help", [], "info", "", true],
|
|
47
|
+
["status", [], "info", "", true],
|
|
48
|
+
["doctor", ["checkup"], "info", "", true],
|
|
49
|
+
["usage", ["cost", "stats"], "info", "", true],
|
|
50
|
+
["release-notes", [], "info", "", true],
|
|
51
|
+
["bug", ["share"], "info", "[report]", true],
|
|
52
|
+
["feedback", [], "info", "[report]", true],
|
|
53
|
+
["version", [], "info", "", true, true],
|
|
54
|
+
["shell-reply", [], "config", "[on|off]", true],
|
|
55
|
+
["login", [], "account", "", true],
|
|
56
|
+
["logout", [], "account", "", true],
|
|
57
|
+
["plans", ["upgrade"], "account", "", true],
|
|
58
|
+
["topup", ["usage-credits"], "account", "", true],
|
|
59
|
+
["phone", ["mobile", "remote-control"], "bitpull", "", true],
|
|
60
|
+
["lend", [], "bitpull", "[on|off]", true],
|
|
61
|
+
["pause", [], "bitpull", "", true],
|
|
62
|
+
["network", [], "bitpull", "", true],
|
|
63
|
+
["groups", [], "bitpull", "", true],
|
|
64
|
+
["models", [], "bitpull", "", true],
|
|
65
|
+
["publish", [], "bitpull", "", true],
|
|
66
|
+
["terminal", [], "bitpull", "", true],
|
|
67
|
+
["projects", [], "bitpull", "", true],
|
|
68
|
+
["chat", [], "bitpull", "", true],
|
|
69
|
+
];
|
|
70
|
+
const CATEGORIES = ["session", "context", "model", "code", "agents", "extensions", "config", "info", "account", "bitpull", "custom"];
|
|
71
|
+
|
|
72
|
+
/** Default keys; the person's ~/.bitpull/keybindings.json replaces any of them. */
|
|
73
|
+
const DEFAULT_KEYS = {
|
|
74
|
+
"chat:submit": ["Enter"],
|
|
75
|
+
"chat:newline": ["Shift+Enter", "Alt+Enter", "Ctrl+J"],
|
|
76
|
+
"chat:interrupt": ["Escape"],
|
|
77
|
+
"chat:clearInput": ["Escape Escape"],
|
|
78
|
+
"history:search": ["Ctrl+R"],
|
|
79
|
+
"chat:stash": ["Ctrl+S"],
|
|
80
|
+
"chat:modelPicker": ["Alt+P"],
|
|
81
|
+
"chat:cycleMode": ["Shift+Tab"],
|
|
82
|
+
"chat:sendNow": ["Ctrl+Enter", "Ctrl+X Ctrl+S"],
|
|
83
|
+
"help:shortcuts": ["?"],
|
|
84
|
+
"edit:killToEnd": ["Ctrl+K"],
|
|
85
|
+
"edit:killToStart": ["Ctrl+U"],
|
|
86
|
+
"edit:killWord": ["Ctrl+W"],
|
|
87
|
+
"edit:yank": ["Ctrl+Y"],
|
|
88
|
+
"edit:wordBack": ["Alt+B"],
|
|
89
|
+
"edit:wordForward": ["Alt+F"],
|
|
90
|
+
"edit:deleteWord": ["Alt+D"],
|
|
91
|
+
"edit:undo": ["Ctrl+_"],
|
|
92
|
+
};
|
|
93
|
+
/** Line start and end: on a Mac the text box does it already; elsewhere Ctrl+A stays "select all". */
|
|
94
|
+
if (/Mac/i.test(navigator.platform || navigator.userAgent)) Object.assign(DEFAULT_KEYS, { "edit:lineStart": ["Ctrl+A"], "edit:lineEnd": ["Ctrl+E"] });
|
|
95
|
+
|
|
96
|
+
const EMOJI = {
|
|
97
|
+
heart: "❤️", smile: "😄", grin: "😁", joy: "😂", laughing: "😆", wink: "😉", blush: "😊", thinking: "🤔", cry: "😢", sob: "😭",
|
|
98
|
+
"+1": "👍", thumbsup: "👍", "-1": "👎", thumbsdown: "👎", clap: "👏", pray: "🙏", wave: "👋", ok_hand: "👌", muscle: "💪", eyes: "👀",
|
|
99
|
+
fire: "🔥", rocket: "🚀", tada: "🎉", sparkles: "✨", star: "⭐", "100": "💯", check: "✅", white_check_mark: "✅", x: "❌", warning: "⚠️",
|
|
100
|
+
bug: "🐛", wrench: "🔧", hammer: "🔨", gear: "⚙️", zap: "⚡", bulb: "💡", memo: "📝", lock: "🔒", key: "🔑", package: "📦",
|
|
101
|
+
computer: "💻", phone: "📱", robot: "🤖", brain: "🧠", chart: "📈", coffee: "☕", pizza: "🍕", beer: "🍺", sun: "☀️", moon: "🌙",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const INVISIBLE = /[----]|[\u{E0000}-\u{E007F}]/gu;
|
|
105
|
+
const norm = (s) => String(s).toLowerCase().replace(/[:_-]/g, "");
|
|
106
|
+
const words = (s) => String(s).toLowerCase().split(/[:_-]+/).filter(Boolean);
|
|
107
|
+
|
|
108
|
+
/** Does `q` (already normalised) match this name: from the start, or from a word inside it. */
|
|
109
|
+
const strong = (name, q) => norm(name).startsWith(q) || words(name).some((w) => w.startsWith(q));
|
|
110
|
+
/** Near: a few letters wrong, missing or swapped (a typing mistake shows them, never chosen by Enter). */
|
|
111
|
+
const distance = (a, b) => {
|
|
112
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
113
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
114
|
+
for (let i = 1; i <= a.length; i++) {
|
|
115
|
+
for (let j = 1; j <= b.length; j++) {
|
|
116
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
117
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return d[a.length][b.length];
|
|
121
|
+
};
|
|
122
|
+
const near = (name, q) => {
|
|
123
|
+
if (q.length < 3) return false;
|
|
124
|
+
const n = norm(name);
|
|
125
|
+
const most = Math.max(1, Math.floor(q.length / 3));
|
|
126
|
+
return distance(n, q) <= most || distance(n.slice(0, q.length), q) <= most;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function createRegistry() {
|
|
130
|
+
const cmds = new Map();
|
|
131
|
+
return {
|
|
132
|
+
add(c) {
|
|
133
|
+
cmds.set(c.name, { aliases: [], args: "", category: "custom", kind: "builtin", hidden: false, runsImmediately: false, isAvailable: () => true, ...c });
|
|
134
|
+
},
|
|
135
|
+
clearKind(kind) {
|
|
136
|
+
for (const [k, c] of cmds) if (c.kind === kind) cmds.delete(k);
|
|
137
|
+
},
|
|
138
|
+
all() {
|
|
139
|
+
return [...cmds.values()].filter((c) => c.isAvailable());
|
|
140
|
+
},
|
|
141
|
+
find(name) {
|
|
142
|
+
const n = String(name).toLowerCase();
|
|
143
|
+
const list = this.all();
|
|
144
|
+
return list.find((c) => c.name.toLowerCase() === n) || list.find((c) => c.aliases.some((a) => a.toLowerCase() === n))
|
|
145
|
+
// A plugin's command by its short name, when only one has it.
|
|
146
|
+
|| ((m) => (m.length === 1 ? m[0] : null))(list.filter((c) => c.name.includes(":") && c.name.split(":").pop().toLowerCase() === n));
|
|
147
|
+
},
|
|
148
|
+
/** What to show for "/q": strong matches (the first is chosen), or near ones (none chosen). */
|
|
149
|
+
match(q) {
|
|
150
|
+
const nq = norm(q);
|
|
151
|
+
const list = this.all();
|
|
152
|
+
const order = (a, b) => CATEGORIES.indexOf(a.category) - CATEGORIES.indexOf(b.category) || a.name.localeCompare(b.name);
|
|
153
|
+
if (!nq) return { items: list.filter((c) => !c.hidden).sort(order), chosen: 0 };
|
|
154
|
+
const hit = list.filter((c) => (c.hidden ? norm(c.name) === nq : strong(c.name, nq) || c.aliases.some((a) => strong(a, nq))));
|
|
155
|
+
if (hit.length) {
|
|
156
|
+
const exact = (c) => norm(c.name) === nq || c.aliases.some((a) => norm(a) === nq);
|
|
157
|
+
const starts = (c) => norm(c.name).startsWith(nq) || c.aliases.some((a) => norm(a).startsWith(nq));
|
|
158
|
+
hit.sort((a, b) => Number(exact(b)) - Number(exact(a)) || Number(starts(b)) - Number(starts(a)) || order(a, b));
|
|
159
|
+
return { items: hit, chosen: 0 };
|
|
160
|
+
}
|
|
161
|
+
return { items: list.filter((c) => !c.hidden && (near(c.name, nq) || c.aliases.some((a) => near(a, nq)))).sort(order).slice(0, 8), chosen: -1 };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** "ctrl+x ctrl+s" → "Ctrl+X Ctrl+S", as keyName() writes them. */
|
|
167
|
+
const MOD = { ctrl: "Ctrl", control: "Ctrl", alt: "Alt", option: "Alt", opt: "Alt", shift: "Shift", meta: "Meta", cmd: "Meta", command: "Meta", super: "Meta" };
|
|
168
|
+
const NAMED = { enter: "Enter", return: "Enter", esc: "Escape", escape: "Escape", tab: "Tab", space: "Space", backspace: "Backspace", delete: "Delete", up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight" };
|
|
169
|
+
const normKey = (combo) => String(combo).trim().split(/\s+/).map((step) => {
|
|
170
|
+
const parts = step.split("+").filter(Boolean);
|
|
171
|
+
const key = parts.pop() || "";
|
|
172
|
+
const mods = [...new Set(parts.map((m) => MOD[m.toLowerCase()] || m))].sort((a, b) => ["Ctrl", "Alt", "Shift", "Meta"].indexOf(a) - ["Ctrl", "Alt", "Shift", "Meta"].indexOf(b));
|
|
173
|
+
const k = NAMED[key.toLowerCase()] || (key.length === 1 ? key.toUpperCase() : key);
|
|
174
|
+
// Shift with one character is already in the character ("?" not "Shift+/").
|
|
175
|
+
return [...mods.filter((m) => !(m === "Shift" && k.length === 1)), k].join("+");
|
|
176
|
+
}).join(" ");
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The person's keybindings.json, in either form:
|
|
180
|
+
* { "chat:sendNow": ["Ctrl+Enter"] } an action and its keys
|
|
181
|
+
* { "ctrl+x ctrl+s": "chat:sendNow", "ctrl+s": null } keys and their action; null takes a key away
|
|
182
|
+
* { "bindings": [{ "context": "Chat", "bindings": {…} }] } as in Claude Code
|
|
183
|
+
*/
|
|
184
|
+
function applyKeymap(base, raw) {
|
|
185
|
+
const keys = Object.fromEntries(Object.entries(base).map(([a, l]) => [a, [...l]]));
|
|
186
|
+
const unbind = (k) => { for (const a of Object.keys(keys)) keys[a] = keys[a].filter((x) => x !== k); };
|
|
187
|
+
const known = (a) => a in DEFAULT_KEYS || a === "edit:lineStart" || a === "edit:lineEnd";
|
|
188
|
+
const one = (obj) => {
|
|
189
|
+
if (!obj || typeof obj !== "object") return;
|
|
190
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
191
|
+
if (known(k)) { keys[k] = [].concat(v || []).map(normKey); continue; }
|
|
192
|
+
const combo = normKey(k);
|
|
193
|
+
if (v === null) { unbind(combo); continue; }
|
|
194
|
+
if (typeof v === "string" && known(v)) { unbind(combo); keys[v] = [...(keys[v] || []), combo]; }
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
if (Array.isArray(raw.bindings)) for (const b of raw.bindings) one(b && b.bindings);
|
|
198
|
+
else one(raw);
|
|
199
|
+
return keys;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** "Ctrl+X Ctrl+S" and the like: does this key event finish one of these combinations? */
|
|
203
|
+
const keyName = (e) => {
|
|
204
|
+
const parts = [];
|
|
205
|
+
if (e.ctrlKey) parts.push("Ctrl");
|
|
206
|
+
if (e.altKey) parts.push("Alt");
|
|
207
|
+
if (e.shiftKey && e.key.length > 1) parts.push("Shift");
|
|
208
|
+
if (e.metaKey) parts.push("Meta");
|
|
209
|
+
let k = e.key;
|
|
210
|
+
if (e.altKey && /^Key[A-Z]$/.test(e.code)) k = e.code.slice(3);
|
|
211
|
+
if (k.length === 1) k = k.toUpperCase();
|
|
212
|
+
if (k === " ") k = "Space";
|
|
213
|
+
if (!["Control", "Alt", "Shift", "Meta"].includes(e.key)) parts.push(k);
|
|
214
|
+
return parts.join("+");
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Plugs the quick commands into a text box. `o`:
|
|
219
|
+
* t(key, vars) translation
|
|
220
|
+
* surface "projects" or "chat"
|
|
221
|
+
* registry from createRegistry(), with each page's built-in actions
|
|
222
|
+
* send(text) an ordinary message
|
|
223
|
+
* shell(command) "!" (missing: the page says where it works)
|
|
224
|
+
* runSkill(name, args) a custom command
|
|
225
|
+
* loadSkills() the custom commands for the menu
|
|
226
|
+
* busy() the model is answering: commands wait in a queue
|
|
227
|
+
* say(text, kind) a short line under the box
|
|
228
|
+
* interrupt() Esc while the model works
|
|
229
|
+
* pickModel(), cycleMode()
|
|
230
|
+
*/
|
|
231
|
+
function attach(input, o) {
|
|
232
|
+
const t = o.t;
|
|
233
|
+
let keys = { ...DEFAULT_KEYS };
|
|
234
|
+
let keymapFile = "";
|
|
235
|
+
fetch("/api/quick/keymap", { headers: { "x-bitpull-token": (window.__BITPULL__ || {}).token || "" } })
|
|
236
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
237
|
+
.then((r) => {
|
|
238
|
+
if (r && r.keymap) keys = applyKeymap(keys, r.keymap);
|
|
239
|
+
keymapFile = (r && r.file) || "";
|
|
240
|
+
})
|
|
241
|
+
.catch(() => {});
|
|
242
|
+
const is = (action, name) => (keys[action] || []).includes(name);
|
|
243
|
+
|
|
244
|
+
const host = o.host || input.parentElement;
|
|
245
|
+
if (getComputedStyle(host).position === "static") host.style.position = "relative";
|
|
246
|
+
const menu = document.createElement("ul");
|
|
247
|
+
menu.className = "qc-menu";
|
|
248
|
+
menu.setAttribute("role", "listbox");
|
|
249
|
+
menu.hidden = true;
|
|
250
|
+
host.appendChild(menu);
|
|
251
|
+
const panel = document.createElement("div");
|
|
252
|
+
panel.className = "qc-panel";
|
|
253
|
+
panel.hidden = true;
|
|
254
|
+
host.appendChild(panel);
|
|
255
|
+
|
|
256
|
+
let items = [];
|
|
257
|
+
let chosen = -1;
|
|
258
|
+
let kind = null; // "slash" | "emoji" | "history"
|
|
259
|
+
let emojiStart = -1;
|
|
260
|
+
const queue = [];
|
|
261
|
+
let pendingInvisible = null;
|
|
262
|
+
let chord = null;
|
|
263
|
+
let chordTimer = 0;
|
|
264
|
+
let stash = null;
|
|
265
|
+
const killRing = [];
|
|
266
|
+
const undoStack = [];
|
|
267
|
+
const histKey = `bp_qc_history_${o.surface}`;
|
|
268
|
+
const shellKey = () => `bp_qc_shell_${o.surface}_${o.scope ? o.scope() : ""}`;
|
|
269
|
+
const load = (k) => { try { return JSON.parse(localStorage.getItem(k) || "[]"); } catch { return []; } };
|
|
270
|
+
const save = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v.slice(-200))); } catch {} };
|
|
271
|
+
let history = load(histKey);
|
|
272
|
+
let histPos = -1;
|
|
273
|
+
|
|
274
|
+
const close = () => { menu.hidden = true; items = []; chosen = -1; kind = null; };
|
|
275
|
+
const draw = () => {
|
|
276
|
+
menu.replaceChildren();
|
|
277
|
+
if (!items.length) { menu.hidden = true; return; }
|
|
278
|
+
items.forEach((it, i) => {
|
|
279
|
+
const li = document.createElement("li");
|
|
280
|
+
li.className = i === chosen ? "on" : "";
|
|
281
|
+
li.setAttribute("role", "option");
|
|
282
|
+
const a = document.createElement("span");
|
|
283
|
+
a.className = "qc-name";
|
|
284
|
+
a.textContent = it.label;
|
|
285
|
+
const b = document.createElement("span");
|
|
286
|
+
b.className = "qc-desc";
|
|
287
|
+
b.textContent = it.desc || "";
|
|
288
|
+
li.append(a, b);
|
|
289
|
+
li.addEventListener("mousedown", (e) => { e.preventDefault(); chosen = i; accept(true); });
|
|
290
|
+
menu.appendChild(li);
|
|
291
|
+
});
|
|
292
|
+
menu.hidden = false;
|
|
293
|
+
menu.querySelector("li.on")?.scrollIntoView({ block: "nearest" });
|
|
294
|
+
};
|
|
295
|
+
const describe = (c) => (c.kind === "builtin" ? t(`qc.${c.name}`) : c.description || t("qc.custom"));
|
|
296
|
+
|
|
297
|
+
async function refreshSkills() {
|
|
298
|
+
if (!o.loadSkills) return;
|
|
299
|
+
try {
|
|
300
|
+
const list = await o.loadSkills();
|
|
301
|
+
o.registry.clearKind("skill");
|
|
302
|
+
o.registry.clearKind("plugin");
|
|
303
|
+
for (const s of list) o.registry.add({ name: s.name, description: s.description, args: s.argumentHint, category: "custom", kind: s.plugin ? "plugin" : "skill", handler: (args) => o.runSkill(s.name, args) });
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
refreshSkills();
|
|
307
|
+
|
|
308
|
+
/** Shows the menu for what is typed now. */
|
|
309
|
+
function update() {
|
|
310
|
+
const v = input.value;
|
|
311
|
+
const caret = input.selectionStart ?? v.length;
|
|
312
|
+
input.classList.toggle("qc-shell", v.startsWith("!"));
|
|
313
|
+
// "/" only opens a command when it starts the message, and only while its name is being typed.
|
|
314
|
+
const slash = /^\/(\S*)$/.exec(v.slice(0, caret));
|
|
315
|
+
if (slash && v.indexOf(" ") === -1 || slash && caret <= v.indexOf(" ")) {
|
|
316
|
+
const m = o.registry.match(slash[1]);
|
|
317
|
+
kind = "slash";
|
|
318
|
+
items = m.items.map((c) => ({ label: `/${c.name}${c.args ? ` ${c.args}` : ""}`, desc: describe(c), cmd: c }));
|
|
319
|
+
chosen = m.chosen;
|
|
320
|
+
if (!items.length) items = [{ label: t("qc.nomatch", { q: slash[1] }), desc: "", none: true }];
|
|
321
|
+
return draw();
|
|
322
|
+
}
|
|
323
|
+
// ":" and two letters: emoji suggestions; ":name:" becomes the emoji.
|
|
324
|
+
const before = v.slice(0, caret);
|
|
325
|
+
const full = /(^|\s):([a-z0-9_+-]{1,30}):$/.exec(before);
|
|
326
|
+
if (full && EMOJI[full[2]]) {
|
|
327
|
+
const start = caret - full[2].length - 2;
|
|
328
|
+
setValue(v.slice(0, start) + EMOJI[full[2]] + v.slice(caret), start + EMOJI[full[2]].length);
|
|
329
|
+
return close();
|
|
330
|
+
}
|
|
331
|
+
const part = /(^|\s):([a-z0-9_+-]{2,30})$/.exec(before);
|
|
332
|
+
if (part) {
|
|
333
|
+
const q = part[2];
|
|
334
|
+
const found = Object.entries(EMOJI).filter(([k]) => k.startsWith(q)).slice(0, 8);
|
|
335
|
+
if (found.length) {
|
|
336
|
+
kind = "emoji";
|
|
337
|
+
emojiStart = caret - q.length - 1;
|
|
338
|
+
items = found.map(([k, e]) => ({ label: `${e} :${k}:`, emoji: e }));
|
|
339
|
+
chosen = 0;
|
|
340
|
+
return draw();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (kind !== "history") close();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function setValue(v, caret) {
|
|
347
|
+
undoStack.push({ v: input.value, c: input.selectionStart });
|
|
348
|
+
if (undoStack.length > 100) undoStack.shift();
|
|
349
|
+
input.value = v;
|
|
350
|
+
const at = caret ?? v.length;
|
|
351
|
+
input.setSelectionRange(at, at);
|
|
352
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Tab or a click: the chosen suggestion goes into the box; with `run`, a command also runs. */
|
|
356
|
+
function accept(run) {
|
|
357
|
+
const it = items[chosen];
|
|
358
|
+
if (!it || it.none) return false;
|
|
359
|
+
if (kind === "slash") {
|
|
360
|
+
if (run && !it.cmd.args.startsWith("<")) { close(); setValue(""); dispatch(`/${it.cmd.name}`); return true; }
|
|
361
|
+
setValue(`/${it.cmd.name} `);
|
|
362
|
+
close();
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (kind === "emoji") {
|
|
366
|
+
const v = input.value;
|
|
367
|
+
const caret = input.selectionStart;
|
|
368
|
+
setValue(v.slice(0, emojiStart) + it.emoji + v.slice(caret), emojiStart + it.emoji.length);
|
|
369
|
+
close();
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
if (kind === "history") {
|
|
373
|
+
setValue(it.text);
|
|
374
|
+
close();
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** One message from the box: a command, a skill (up to six in a row), "!", or a plain message. */
|
|
381
|
+
async function dispatch(text) {
|
|
382
|
+
if (text.startsWith("!")) {
|
|
383
|
+
const command = text.slice(1).trim();
|
|
384
|
+
if (!command) return;
|
|
385
|
+
if (!o.shell) return o.say(t("qc.shell.where"), "warn");
|
|
386
|
+
const past = load(shellKey()).filter((c) => c !== command);
|
|
387
|
+
save(shellKey(), [...past, command]);
|
|
388
|
+
if (o.busy()) return enqueue(text, false);
|
|
389
|
+
return o.shell(command);
|
|
390
|
+
}
|
|
391
|
+
if (text.startsWith("/")) {
|
|
392
|
+
const m = /^\/(\S+)\s*([\s\S]*)$/.exec(text);
|
|
393
|
+
const c = m && o.registry.find(m[1]);
|
|
394
|
+
if (!c) return o.say(t("qc.unknown", { name: m ? m[1] : "" }), "warn");
|
|
395
|
+
if (c.kind !== "builtin") {
|
|
396
|
+
// "/a /b do X": every skill named first gets "do X".
|
|
397
|
+
const chain = [c];
|
|
398
|
+
let rest = m[2];
|
|
399
|
+
for (let next = /^\/(\S+)\s*([\s\S]*)$/.exec(rest); next && chain.length < 6; next = /^\/(\S+)\s*([\s\S]*)$/.exec(rest)) {
|
|
400
|
+
const s = o.registry.find(next[1]);
|
|
401
|
+
if (!s || s.kind === "builtin") break;
|
|
402
|
+
chain.push(s);
|
|
403
|
+
rest = next[2];
|
|
404
|
+
}
|
|
405
|
+
if (o.busy()) return enqueue(text, false);
|
|
406
|
+
for (const s of chain) await s.handler(rest.trim());
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (o.busy() && !c.runsImmediately) return enqueue(text, false);
|
|
410
|
+
return c.handler(m[2].trim());
|
|
411
|
+
}
|
|
412
|
+
if (o.busy()) return enqueue(text, true);
|
|
413
|
+
return o.send(text);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function enqueue(text) {
|
|
417
|
+
queue.push(text);
|
|
418
|
+
o.say(t("qc.queued", { n: queue.length }), "info");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The page calls this when the model has finished: what waits goes, in order. */
|
|
422
|
+
async function idle() {
|
|
423
|
+
if (!queue.length) return;
|
|
424
|
+
// The "in the queue" line goes first: what the queued commands say stays on screen.
|
|
425
|
+
o.say("", "info");
|
|
426
|
+
while (queue.length && !o.busy()) {
|
|
427
|
+
const next = queue.shift();
|
|
428
|
+
await dispatch(next);
|
|
429
|
+
if (o.busy()) break;
|
|
430
|
+
}
|
|
431
|
+
if (queue.length) o.say(t("qc.queued", { n: queue.length }), "info");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function remember(text) {
|
|
435
|
+
history = [...history.filter((h) => h !== text), text];
|
|
436
|
+
save(histKey, history);
|
|
437
|
+
histPos = -1;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function submit() {
|
|
441
|
+
let text = input.value;
|
|
442
|
+
// Invisible characters are taken out, and the person confirms with a second Enter.
|
|
443
|
+
if (INVISIBLE.test(text)) {
|
|
444
|
+
INVISIBLE.lastIndex = 0;
|
|
445
|
+
const clean = text.replace(INVISIBLE, "");
|
|
446
|
+
if (pendingInvisible !== clean) {
|
|
447
|
+
pendingInvisible = clean;
|
|
448
|
+
setValue(clean);
|
|
449
|
+
return o.say(t("qc.invisible"), "warn");
|
|
450
|
+
}
|
|
451
|
+
text = clean;
|
|
452
|
+
}
|
|
453
|
+
INVISIBLE.lastIndex = 0;
|
|
454
|
+
pendingInvisible = null;
|
|
455
|
+
text = text.trim();
|
|
456
|
+
if (!text) return;
|
|
457
|
+
// A command being typed with the chosen suggestion: Enter runs that one.
|
|
458
|
+
if (kind === "slash" && chosen >= 0 && !text.includes(" ") && items[chosen]?.cmd) text = `/${items[chosen].cmd.name}`;
|
|
459
|
+
close();
|
|
460
|
+
remember(text);
|
|
461
|
+
setValue("");
|
|
462
|
+
await dispatch(text);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function shortcuts() {
|
|
466
|
+
if (!panel.hidden) { panel.hidden = true; return; }
|
|
467
|
+
panel.replaceChildren();
|
|
468
|
+
const h = document.createElement("strong");
|
|
469
|
+
h.textContent = t("qc.keys.title");
|
|
470
|
+
panel.appendChild(h);
|
|
471
|
+
const rows = [
|
|
472
|
+
["/", t("qc.keys.slash")], ["!", t("qc.keys.bang")], ["@", t("qc.keys.at")], [":", t("qc.keys.colon")],
|
|
473
|
+
...Object.entries(keys).map(([a, k]) => [[].concat(k).join(" · "), t(`qc.key.${a}`)]),
|
|
474
|
+
];
|
|
475
|
+
const dl = document.createElement("dl");
|
|
476
|
+
for (const [k, d] of rows) {
|
|
477
|
+
const dt = document.createElement("dt");
|
|
478
|
+
dt.textContent = k;
|
|
479
|
+
const dd = document.createElement("dd");
|
|
480
|
+
dd.textContent = d;
|
|
481
|
+
dl.append(dt, dd);
|
|
482
|
+
}
|
|
483
|
+
panel.appendChild(dl);
|
|
484
|
+
panel.hidden = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- editing as in a terminal (readline) ----------------------------------------------------
|
|
488
|
+
const lineStart = (v, c) => v.lastIndexOf("\n", c - 1) + 1;
|
|
489
|
+
const lineEnd = (v, c) => ((i) => (i < 0 ? v.length : i))(v.indexOf("\n", c));
|
|
490
|
+
const wordLeft = (v, c) => { let i = c; while (i > 0 && /\W/.test(v[i - 1])) i--; while (i > 0 && /\w/.test(v[i - 1])) i--; return i; };
|
|
491
|
+
const wordRight = (v, c) => { let i = c; while (i < v.length && /\W/.test(v[i])) i++; while (i < v.length && /\w/.test(v[i])) i++; return i; };
|
|
492
|
+
const spaceLeft = (v, c) => { let i = c; while (i > 0 && /\s/.test(v[i - 1])) i--; while (i > 0 && !/\s/.test(v[i - 1])) i--; return i; };
|
|
493
|
+
const kill = (from, to) => {
|
|
494
|
+
const v = input.value;
|
|
495
|
+
if (from === to) return;
|
|
496
|
+
killRing.push(v.slice(from, to));
|
|
497
|
+
if (killRing.length > 20) killRing.shift();
|
|
498
|
+
setValue(v.slice(0, from) + v.slice(to), from);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
input.addEventListener("input", update);
|
|
502
|
+
input.addEventListener("click", update);
|
|
503
|
+
input.addEventListener("blur", () => setTimeout(close, 150));
|
|
504
|
+
input.addEventListener("paste", (e) => {
|
|
505
|
+
// Pasting text that starts with "!" in an empty box enters shell mode, as typing it does.
|
|
506
|
+
const text = e.clipboardData && e.clipboardData.getData("text");
|
|
507
|
+
if (text && text.startsWith("!") && !input.value) setTimeout(update, 0);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
input.addEventListener("keydown", (e) => {
|
|
511
|
+
if (e.isComposing || e.defaultPrevented) return;
|
|
512
|
+
const name = keyName(e);
|
|
513
|
+
const v = input.value;
|
|
514
|
+
const c = input.selectionStart ?? 0;
|
|
515
|
+
|
|
516
|
+
// A combination in two steps (Ctrl+X then Ctrl+S).
|
|
517
|
+
if (chord) {
|
|
518
|
+
const full = `${chord} ${name}`;
|
|
519
|
+
chord = null;
|
|
520
|
+
clearTimeout(chordTimer);
|
|
521
|
+
o.say("", "info");
|
|
522
|
+
for (const [action, list] of Object.entries(keys)) if (list.includes(full)) { e.preventDefault(); return run(action); }
|
|
523
|
+
}
|
|
524
|
+
// A prefix key (Ctrl+X) waits up to three seconds for the second one, and says so.
|
|
525
|
+
// A key that is also an action of its own (Esc, then Esc Esc) does its action as well.
|
|
526
|
+
// With text selected Ctrl+X still cuts it, as everywhere.
|
|
527
|
+
if (input.selectionStart === input.selectionEnd && Object.values(keys).some((list) => list.some((k) => k.startsWith(`${name} `)))) {
|
|
528
|
+
chord = name;
|
|
529
|
+
clearTimeout(chordTimer);
|
|
530
|
+
chordTimer = setTimeout(() => { if (chord === name) { chord = null; o.say("", "info"); } }, 3000);
|
|
531
|
+
if (!Object.values(keys).some((list) => list.includes(name))) {
|
|
532
|
+
o.say(t("qc.chord", { key: name }), "info");
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// The menu first.
|
|
539
|
+
if (!menu.hidden && items.length) {
|
|
540
|
+
if (e.key === "ArrowDown") { chosen = Math.min(items.length - 1, chosen + 1); draw(); return e.preventDefault(); }
|
|
541
|
+
if (e.key === "ArrowUp") { chosen = Math.max(0, chosen - 1); draw(); return e.preventDefault(); }
|
|
542
|
+
if (e.key === "Tab" && !e.shiftKey) { if (chosen < 0) chosen = 0; e.preventDefault(); return void accept(false); }
|
|
543
|
+
if (e.key === "Escape") { e.preventDefault(); return close(); }
|
|
544
|
+
if (e.key === "Enter" && !e.shiftKey && kind !== "slash") { if (chosen >= 0) { e.preventDefault(); return void accept(kind === "slash"); } }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// "?" on an empty box: the shortcuts, instead of the character.
|
|
548
|
+
if (e.key === "?" && !v && is("help:shortcuts", "?")) { e.preventDefault(); return shortcuts(); }
|
|
549
|
+
if (!panel.hidden && e.key === "Escape") { panel.hidden = true; return e.preventDefault(); }
|
|
550
|
+
|
|
551
|
+
// Shell mode: Tab completes from the commands used before in this project; Esc or Backspace at "!" leave it.
|
|
552
|
+
if (v.startsWith("!") && e.key === "Tab" && !e.shiftKey) {
|
|
553
|
+
const typed = v.slice(1).trimStart();
|
|
554
|
+
const past = load(shellKey()).reverse().find((x) => x.startsWith(typed) && x !== typed);
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
if (past) setValue(`! ${past}`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (v === "!" && (e.key === "Escape" || e.key === "Backspace")) { e.preventDefault(); setValue(""); return; }
|
|
560
|
+
|
|
561
|
+
// A backslash before Enter: a new line, as in a terminal.
|
|
562
|
+
if (e.key === "Enter" && !e.shiftKey && !e.altKey && v[c - 1] === "\\") { e.preventDefault(); setValue(`${v.slice(0, c - 1)}\n${v.slice(c)}`, c); return; }
|
|
563
|
+
|
|
564
|
+
for (const [action, list] of Object.entries(keys)) {
|
|
565
|
+
if (!list.includes(name)) continue;
|
|
566
|
+
if (action === "help:shortcuts") continue;
|
|
567
|
+
if (action === "chat:submit" && e.shiftKey) continue;
|
|
568
|
+
e.preventDefault();
|
|
569
|
+
return run(action);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// History: up from the first line, down from the last.
|
|
573
|
+
if (e.key === "ArrowUp" && !e.shiftKey && c <= (v.indexOf("\n") < 0 ? v.length : v.indexOf("\n")) && (!v || histPos >= 0)) {
|
|
574
|
+
if (!history.length) return;
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
histPos = histPos < 0 ? history.length - 1 : Math.max(0, histPos - 1);
|
|
577
|
+
return setValue(history[histPos]);
|
|
578
|
+
}
|
|
579
|
+
if (e.key === "ArrowDown" && histPos >= 0 && c >= v.lastIndexOf("\n") + 1) {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
histPos++;
|
|
582
|
+
if (histPos >= history.length) { histPos = -1; return setValue(""); }
|
|
583
|
+
return setValue(history[histPos]);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
function run(action) {
|
|
588
|
+
const v = input.value;
|
|
589
|
+
const c = input.selectionStart ?? 0;
|
|
590
|
+
switch (action) {
|
|
591
|
+
case "chat:submit": return void submit();
|
|
592
|
+
case "chat:newline": return setValue(`${v.slice(0, c)}\n${v.slice(input.selectionEnd ?? c)}`, c + 1);
|
|
593
|
+
case "chat:interrupt": if (o.busy() && o.interrupt) o.interrupt(); return;
|
|
594
|
+
// Esc Esc with text: it goes into the history (Up brings it back) and the box empties.
|
|
595
|
+
case "chat:clearInput": if (v.trim()) { remember(v.trim()); setValue(""); o.say(t("qc.cleared"), "info"); } return;
|
|
596
|
+
case "history:search": {
|
|
597
|
+
const q = v.trim().toLowerCase();
|
|
598
|
+
kind = "history";
|
|
599
|
+
items = [...history].reverse().filter((h) => !q || h.toLowerCase().includes(q)).slice(0, 12).map((h) => ({ label: h.split("\n")[0].slice(0, 120), desc: "", text: h }));
|
|
600
|
+
chosen = items.length ? 0 : -1;
|
|
601
|
+
if (!items.length) items = [{ label: t("qc.history.none"), none: true }];
|
|
602
|
+
return draw();
|
|
603
|
+
}
|
|
604
|
+
case "chat:stash":
|
|
605
|
+
if (v.trim()) { stash = v; setValue(""); o.say(t("qc.stashed"), "info"); }
|
|
606
|
+
else if (stash !== null) { setValue(stash); stash = null; }
|
|
607
|
+
return;
|
|
608
|
+
case "chat:modelPicker": return o.pickModel && o.pickModel();
|
|
609
|
+
case "chat:cycleMode": return o.cycleMode && o.cycleMode();
|
|
610
|
+
// Send now: what is in the box joins the queue, the model stops, and the queue goes at once.
|
|
611
|
+
case "chat:sendNow":
|
|
612
|
+
if (v.trim()) { remember(v.trim()); queue.push(v.trim()); setValue(""); }
|
|
613
|
+
if (o.busy()) { if (o.interrupt) o.interrupt(); return; }
|
|
614
|
+
return void idle();
|
|
615
|
+
case "edit:lineStart": return input.setSelectionRange(lineStart(v, c), lineStart(v, c));
|
|
616
|
+
case "edit:lineEnd": return input.setSelectionRange(lineEnd(v, c), lineEnd(v, c));
|
|
617
|
+
case "edit:killToEnd": return kill(c, lineEnd(v, c) === c ? Math.min(v.length, c + 1) : lineEnd(v, c));
|
|
618
|
+
case "edit:killToStart": return kill(lineStart(v, c), c);
|
|
619
|
+
case "edit:killWord": return kill(spaceLeft(v, c), c);
|
|
620
|
+
case "edit:yank": { const last = killRing[killRing.length - 1]; if (last) setValue(v.slice(0, c) + last + v.slice(c), c + last.length); return; }
|
|
621
|
+
case "edit:wordBack": return input.setSelectionRange(wordLeft(v, c), wordLeft(v, c));
|
|
622
|
+
case "edit:wordForward": return input.setSelectionRange(wordRight(v, c), wordRight(v, c));
|
|
623
|
+
case "edit:deleteWord": return kill(c, wordRight(v, c));
|
|
624
|
+
case "edit:undo": { const prev = undoStack.pop(); if (prev) { input.value = prev.v; input.setSelectionRange(prev.c, prev.c); update(); } return; }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return { idle, submit, submitText: (text) => { remember(text); return dispatch(text); }, sendNow: (text) => (o.busy() ? enqueue(text) : o.send(text)), refreshSkills, queue: () => queue.slice(), shortcuts, keys: () => ({ ...keys }), keymapFile: () => keymapFile, set: setValue, update };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Adds the built-in commands that a page knows how to do. */
|
|
632
|
+
function builtins(registry, handlers) {
|
|
633
|
+
for (const [name, aliases, category, args, immediate, hidden] of CATALOG) {
|
|
634
|
+
const handler = handlers[name];
|
|
635
|
+
if (!handler) continue;
|
|
636
|
+
registry.add({ name, aliases, category, args, runsImmediately: !!immediate, hidden: !!hidden, kind: "builtin", handler });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The whole conversation as plain text, for /export and /copy. */
|
|
641
|
+
const download = (name, text) => {
|
|
642
|
+
const a = document.createElement("a");
|
|
643
|
+
a.href = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
|
|
644
|
+
a.download = name;
|
|
645
|
+
document.body.appendChild(a);
|
|
646
|
+
a.click();
|
|
647
|
+
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
window.BitPullQuick = { createRegistry, attach, builtins, CATALOG, CATEGORIES, EMOJI, DEFAULT_KEYS, keyName, normKey, applyKeymap, download };
|
|
651
|
+
|
|
652
|
+
const css = document.createElement("style");
|
|
653
|
+
css.textContent = `
|
|
654
|
+
.qc-menu { position:absolute; left:0; right:0; bottom:calc(100% + 6px); z-index:40; list-style:none; margin:0; padding:6px; max-height:min(50vh,340px); overflow:auto;
|
|
655
|
+
background:var(--surface, var(--bg2, #1f2128)); color:var(--text, var(--fg, #ececec)); border:1px solid var(--line, var(--border, #33363f)); border-radius:12px; box-shadow:0 10px 30px rgba(0,0,0,.3); font-size:13px; }
|
|
656
|
+
.qc-menu li { display:flex; gap:12px; align-items:baseline; padding:6px 10px; border-radius:8px; cursor:pointer; min-height:28px; }
|
|
657
|
+
.qc-menu li.on, .qc-menu li:hover { background:color-mix(in srgb, var(--accent, #7f95ff) 22%, transparent); }
|
|
658
|
+
.qc-menu .qc-name { font-family:ui-monospace, Menlo, Consolas, monospace; white-space:nowrap; flex:0 0 auto; }
|
|
659
|
+
.qc-menu .qc-desc { color:var(--muted, #a2a19b); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
660
|
+
.qc-panel { position:absolute; left:0; right:0; bottom:calc(100% + 6px); z-index:41; padding:12px 14px; max-height:min(60vh,420px); overflow:auto;
|
|
661
|
+
background:var(--surface, var(--bg2, #1f2128)); color:var(--text, var(--fg, #ececec)); border:1px solid var(--line, var(--border, #33363f)); border-radius:12px; box-shadow:0 10px 30px rgba(0,0,0,.3); font-size:13px; }
|
|
662
|
+
.qc-panel dl { display:grid; grid-template-columns:max-content 1fr; gap:4px 14px; margin:8px 0 0; }
|
|
663
|
+
.qc-panel dt { font-family:ui-monospace, Menlo, Consolas, monospace; }
|
|
664
|
+
.qc-panel dd { margin:0; color:var(--muted, #a2a19b); }
|
|
665
|
+
textarea.qc-shell { border-color:var(--warn, #e0a64b) !important; box-shadow:inset 3px 0 0 var(--warn, #e0a64b); font-family:ui-monospace, Menlo, Consolas, monospace; }
|
|
666
|
+
@media (max-width:720px) { .qc-menu li { min-height:40px; } .qc-menu .qc-desc { white-space:normal; } }`;
|
|
667
|
+
document.head.appendChild(css);
|
|
668
|
+
})();
|