bitpull 0.1.21 → 0.1.24

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.
Files changed (135) hide show
  1. package/README.md +4 -1
  2. package/bitpull.cjs +12810 -10867
  3. package/package.json +3 -2
  4. package/ui/ide-terminal.js +57 -0
  5. package/ui/ide.css +97 -0
  6. package/ui/ide.html +134 -0
  7. package/ui/ide.js +910 -0
  8. package/ui/monaco/vs/abap-D-t0cyap.js +1 -0
  9. package/ui/monaco/vs/apex-CcIm7xu6.js +1 -0
  10. package/ui/monaco/vs/assets/css.worker-BpD9FCKP.js +97 -0
  11. package/ui/monaco/vs/assets/editor.worker-_vAIFJDs.js +30 -0
  12. package/ui/monaco/vs/assets/editorWebWorkerMain-DEgFqX2K.js +30 -0
  13. package/ui/monaco/vs/assets/html.worker-CLuInTTy.js +474 -0
  14. package/ui/monaco/vs/assets/json.worker-Csf_TLih.js +62 -0
  15. package/ui/monaco/vs/assets/ts.worker-g3pjEjl4.js +67722 -0
  16. package/ui/monaco/vs/azcli-BA0tQDCg.js +1 -0
  17. package/ui/monaco/vs/basic-languages/monaco.contribution.js +1 -0
  18. package/ui/monaco/vs/bat-C397hTD6.js +1 -0
  19. package/ui/monaco/vs/bicep-DF5aW17k.js +2 -0
  20. package/ui/monaco/vs/cameligo-plsz8qhj.js +1 -0
  21. package/ui/monaco/vs/clojure-Y2auQMzK.js +1 -0
  22. package/ui/monaco/vs/coffee-Bu45yuWE.js +1 -0
  23. package/ui/monaco/vs/cpp-CkKPQIni.js +1 -0
  24. package/ui/monaco/vs/csharp-CX28MZyh.js +1 -0
  25. package/ui/monaco/vs/csp-D8uWnyxW.js +1 -0
  26. package/ui/monaco/vs/css-CaeNmE3S.js +3 -0
  27. package/ui/monaco/vs/css.worker-BInK4lsP.js +1 -0
  28. package/ui/monaco/vs/cssMode-eIUIN_ru.js +1 -0
  29. package/ui/monaco/vs/cypher-DVThT8BS.js +1 -0
  30. package/ui/monaco/vs/dart-CmGfCvrO.js +1 -0
  31. package/ui/monaco/vs/dockerfile-CZqqYdch.js +1 -0
  32. package/ui/monaco/vs/ecl-30fUercY.js +1 -0
  33. package/ui/monaco/vs/editor/editor.main.css +1 -0
  34. package/ui/monaco/vs/editor/editor.main.js +1 -0
  35. package/ui/monaco/vs/editor/editor.worker.js +1 -0
  36. package/ui/monaco/vs/editor-BdtEMBbM.js +892 -0
  37. package/ui/monaco/vs/editor.js +1 -0
  38. package/ui/monaco/vs/editorWorkerHost-B_VpI68A.js +35 -0
  39. package/ui/monaco/vs/elixir-xjPaIfzF.js +1 -0
  40. package/ui/monaco/vs/flow9-DqtmStfK.js +1 -0
  41. package/ui/monaco/vs/freemarker2-B-FhRNBn.js +3 -0
  42. package/ui/monaco/vs/fsharp-BOMdg4U1.js +1 -0
  43. package/ui/monaco/vs/go-D_hbi-Jt.js +1 -0
  44. package/ui/monaco/vs/graphql-CKUU4kLG.js +1 -0
  45. package/ui/monaco/vs/handlebars-BJBfNnbX.js +1 -0
  46. package/ui/monaco/vs/hcl-DTaboeZW.js +1 -0
  47. package/ui/monaco/vs/html-BTio3wpy.js +1 -0
  48. package/ui/monaco/vs/html.worker-C1TNvQ72.js +1 -0
  49. package/ui/monaco/vs/htmlMode-aw5QYZo7.js +1 -0
  50. package/ui/monaco/vs/index-049xrXrp.js +1 -0
  51. package/ui/monaco/vs/index-DZH_zysX.js +5 -0
  52. package/ui/monaco/vs/index.js +1 -0
  53. package/ui/monaco/vs/ini-CsNwO04R.js +1 -0
  54. package/ui/monaco/vs/initialize-CAIC7-3s.js +1 -0
  55. package/ui/monaco/vs/java-CI4ZMsH9.js +1 -0
  56. package/ui/monaco/vs/javascript-B_IYblwB.js +1 -0
  57. package/ui/monaco/vs/json.worker-Bp7U0-9w.js +1 -0
  58. package/ui/monaco/vs/jsonMode-w7Q_ZLTt.js +1 -0
  59. package/ui/monaco/vs/julia-BwzEvaQw.js +1 -0
  60. package/ui/monaco/vs/kotlin-IUYPiTV8.js +1 -0
  61. package/ui/monaco/vs/less-C0eDYdqa.js +2 -0
  62. package/ui/monaco/vs/lexon-iON-Kj97.js +1 -0
  63. package/ui/monaco/vs/liquid-CXh4HpyA.js +1 -0
  64. package/ui/monaco/vs/loader.js +1368 -0
  65. package/ui/monaco/vs/lspLanguageFeatures-Cog3rVVE.js +2 -0
  66. package/ui/monaco/vs/lua-DtygF91M.js +1 -0
  67. package/ui/monaco/vs/m3-CsR4AuFi.js +1 -0
  68. package/ui/monaco/vs/main-BEx-Fmlo.js +15 -0
  69. package/ui/monaco/vs/main-DsK8pnKg.js +3 -0
  70. package/ui/monaco/vs/markdown-C_rD0bIw.js +1 -0
  71. package/ui/monaco/vs/mdx-BcFcCtz9.js +1 -0
  72. package/ui/monaco/vs/mips-CiYP61RB.js +1 -0
  73. package/ui/monaco/vs/monaco.contribution-BuX2NvO2.js +1 -0
  74. package/ui/monaco/vs/monaco.contribution-C7tY_xnl.js +1 -0
  75. package/ui/monaco/vs/monaco.contribution-CY2lwFI8.js +1 -0
  76. package/ui/monaco/vs/monaco.contribution-D1-wBxcq.js +1 -0
  77. package/ui/monaco/vs/msdax-C38-sJlp.js +1 -0
  78. package/ui/monaco/vs/mysql-CdtbpvbG.js +1 -0
  79. package/ui/monaco/vs/nls/lang/de.js +9 -0
  80. package/ui/monaco/vs/nls/lang/es.js +9 -0
  81. package/ui/monaco/vs/nls/lang/fr.js +9 -0
  82. package/ui/monaco/vs/nls/lang/it.js +9 -0
  83. package/ui/monaco/vs/nls/lang/pt-br.js +9 -0
  84. package/ui/monaco/vs/nls/lang/ru.js +9 -0
  85. package/ui/monaco/vs/nls/lang/zh-cn.js +9 -0
  86. package/ui/monaco/vs/nls.messages-loader.js +1 -0
  87. package/ui/monaco/vs/objective-c-CntZFaHX.js +1 -0
  88. package/ui/monaco/vs/pascal-r6kuqfl_.js +1 -0
  89. package/ui/monaco/vs/pascaligo-BiXoTmXh.js +1 -0
  90. package/ui/monaco/vs/perl-DABw_TcH.js +1 -0
  91. package/ui/monaco/vs/pgsql-me_jFXeX.js +1 -0
  92. package/ui/monaco/vs/php-D_kh-9LK.js +1 -0
  93. package/ui/monaco/vs/pla-VfZjczW0.js +1 -0
  94. package/ui/monaco/vs/postiats-BBSzz8Pk.js +1 -0
  95. package/ui/monaco/vs/powerquery-Dt-g_2cc.js +1 -0
  96. package/ui/monaco/vs/powershell-B-7ap1zc.js +1 -0
  97. package/ui/monaco/vs/protobuf-BmtuEB1A.js +2 -0
  98. package/ui/monaco/vs/pug-BRpRNeEb.js +1 -0
  99. package/ui/monaco/vs/python-CasA8BlK.js +1 -0
  100. package/ui/monaco/vs/qsharp-BzsFaUU9.js +1 -0
  101. package/ui/monaco/vs/r-f8dDdrp4.js +1 -0
  102. package/ui/monaco/vs/razor-IGOwKAHu.js +1 -0
  103. package/ui/monaco/vs/redis-fvZQY4PI.js +1 -0
  104. package/ui/monaco/vs/redshift-45Et0LQi.js +1 -0
  105. package/ui/monaco/vs/restructuredtext-C7UUFKFD.js +1 -0
  106. package/ui/monaco/vs/ruby-CZO8zYTz.js +1 -0
  107. package/ui/monaco/vs/rust-Bfetafyc.js +1 -0
  108. package/ui/monaco/vs/sb-3GYllVck.js +1 -0
  109. package/ui/monaco/vs/scala-foMgrKo1.js +1 -0
  110. package/ui/monaco/vs/scheme-CHdMtr7p.js +1 -0
  111. package/ui/monaco/vs/scss-C1cmLt9V.js +3 -0
  112. package/ui/monaco/vs/shell-ClXCKCEW.js +1 -0
  113. package/ui/monaco/vs/solidity-MZ6ExpPy.js +1 -0
  114. package/ui/monaco/vs/sophia-DWkuSsPQ.js +1 -0
  115. package/ui/monaco/vs/sparql-AUGFYSyk.js +1 -0
  116. package/ui/monaco/vs/sql-32GpJSV2.js +1 -0
  117. package/ui/monaco/vs/st-CuDFIVZ_.js +1 -0
  118. package/ui/monaco/vs/swift-t-PfMj7W.js +1 -0
  119. package/ui/monaco/vs/systemverilog-Ch4vA8Yt.js +1 -0
  120. package/ui/monaco/vs/tcl-D74tq1nH.js +1 -0
  121. package/ui/monaco/vs/toggleHighContrast-DuqAmExZ.js +65 -0
  122. package/ui/monaco/vs/ts.worker-BLYAhMkX.js +1 -0
  123. package/ui/monaco/vs/tsMode-DB1GiNun.js +11 -0
  124. package/ui/monaco/vs/twig-C6taOxMV.js +1 -0
  125. package/ui/monaco/vs/typescript-BTLNeq8B.js +1 -0
  126. package/ui/monaco/vs/typespec-8OfoLt6R.js +1 -0
  127. package/ui/monaco/vs/vb-Dyb2648j.js +1 -0
  128. package/ui/monaco/vs/wgsl-BhLXMOR0.js +298 -0
  129. package/ui/monaco/vs/workers-BeMslOxK.js +1 -0
  130. package/ui/monaco/vs/xml-DHCggo_L.js +1 -0
  131. package/ui/monaco/vs/yaml-C-RIivnc.js +1 -0
  132. package/ui/terminal.js +327 -0
  133. package/ui/vendor/addon-fit.mjs +18 -0
  134. package/ui/vendor/xterm.css +285 -0
  135. package/ui/vendor/xterm.mjs +53 -0
package/ui/ide.js ADDED
@@ -0,0 +1,910 @@
1
+ // BitPull projects: an editor and an AI agent on a folder of this computer.
2
+ // Rule of this file: nothing from a file, a model or the network is ever turned into HTML. Text goes in
3
+ // with textContent or into the editor's models; there is no innerHTML here (a test checks it).
4
+ "use strict";
5
+ (function () {
6
+ const BP = window.__BITPULL__ || {};
7
+ const token = BP.token || "";
8
+ const $ = (id) => document.getElementById(id);
9
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
10
+ const store = {
11
+ get(k, d) { try { const v = localStorage.getItem(`bitpull.ide.${k}`); return v === null ? d : JSON.parse(v); } catch { return d; } },
12
+ set(k, v) { try { localStorage.setItem(`bitpull.ide.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
13
+ };
14
+
15
+ /** Builds an element: attributes, then children (strings become text). */
16
+ function el(tag, attrs, ...kids) {
17
+ const n = document.createElement(tag);
18
+ for (const [k, v] of Object.entries(attrs || {})) {
19
+ if (v === undefined || v === null || v === false) continue;
20
+ if (k === "class") n.className = v;
21
+ else if (k === "text") n.textContent = v;
22
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
23
+ else n.setAttribute(k, v === true ? "" : String(v));
24
+ }
25
+ for (const k of kids.flat()) if (k !== null && k !== undefined && k !== false) n.append(typeof k === "string" || typeof k === "number" ? String(k) : k);
26
+ return n;
27
+ }
28
+ const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
29
+
30
+ // ---- words ------------------------------------------------------------------------------------
31
+ let dict = {}, fallback = {};
32
+ const t = (key, vars) => String(dict[key] ?? fallback[key] ?? key).replace(/\{(\w+)\}/g, (_, k) => (vars && vars[k] !== undefined ? vars[k] : `{${k}}`));
33
+ const LANGS = ["en", "zh", "hi", "es", "ar", "fr", "bn", "pt", "ru", "id", "de", "it"];
34
+ let lang = LANGS.includes(BP.lang) ? BP.lang : "en";
35
+ async function loadWords() {
36
+ const get = (l) => fetch(`/i18n/${l}.json`).then((r) => (r.ok ? r.json() : {})).catch(() => ({}));
37
+ [dict, fallback] = await Promise.all([get(lang), lang === "en" ? Promise.resolve({}) : get("en")]);
38
+ document.documentElement.lang = lang;
39
+ document.documentElement.dir = lang === "ar" ? "rtl" : "ltr";
40
+ document.title = `${t("ide.title")} · BitPull`;
41
+ for (const n of document.querySelectorAll("[data-i18n]")) n.textContent = t(n.dataset.i18n);
42
+ for (const n of document.querySelectorAll("[data-i18n-placeholder]")) n.placeholder = t(n.dataset.i18nPlaceholder);
43
+ for (const n of document.querySelectorAll("[data-tip]")) n.setAttribute("aria-label", t(n.dataset.tip));
44
+ }
45
+ /** A known error's words, or the error as it came. */
46
+ function explain(err) {
47
+ const m = String((err && err.message) || err).replace(/^Error:\s*/, "");
48
+ for (const p of ["ide.err.", "pj.err."]) if (dict[p + m] !== undefined || fallback[p + m] !== undefined) return t(p + m);
49
+ if (/Failed to fetch|NetworkError|Load failed/.test(m)) return t("ide.err.app_down");
50
+ return m;
51
+ }
52
+
53
+ // ---- the app's local API ------------------------------------------------------------------------
54
+ async function api(path, body) {
55
+ const res = await fetch(path, { method: body ? "POST" : "GET", headers: { "content-type": "application/json", "x-bitpull-token": token }, body: body ? JSON.stringify(body) : undefined });
56
+ const data = await res.json().catch(() => ({}));
57
+ if (!res.ok) throw new Error(data.error || res.status);
58
+ return data;
59
+ }
60
+
61
+ // ---- tooltips: after a short pause over anything with data-tip ------------------------------------
62
+ let tipTimer = null;
63
+ document.addEventListener("mouseover", (e) => {
64
+ const n = e.target.closest && e.target.closest("[data-tip]");
65
+ clearTimeout(tipTimer);
66
+ if (!n) return;
67
+ tipTimer = setTimeout(() => {
68
+ const box = $("tip");
69
+ box.textContent = t(n.dataset.tip);
70
+ box.hidden = false;
71
+ const r = n.getBoundingClientRect();
72
+ box.style.left = `${Math.max(8, Math.min(r.left, innerWidth - box.offsetWidth - 8))}px`;
73
+ box.style.top = `${r.bottom + 6 + box.offsetHeight > innerHeight ? r.top - box.offsetHeight - 6 : r.bottom + 6}px`;
74
+ }, 700);
75
+ });
76
+ document.addEventListener("mouseout", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
77
+ document.addEventListener("mousedown", () => { clearTimeout(tipTimer); $("tip").hidden = true; });
78
+
79
+ // ---- a modal of our own (the app's window has no native dialogs everywhere) -------------------------
80
+ function modal(title, body, buttons) {
81
+ return new Promise((resolve) => {
82
+ $("modal-title").textContent = title;
83
+ clear($("modal-body")).append(...[].concat(body || []));
84
+ const bar = clear($("modal-buttons"));
85
+ const close = (v) => { $("modal").hidden = true; document.removeEventListener("keydown", onKey, true); resolve(v); };
86
+ const onKey = (e) => { if (e.key === "Escape") { e.preventDefault(); close(null); } };
87
+ document.addEventListener("keydown", onKey, true);
88
+ for (const b of buttons) bar.append(el("button", { class: b.ghost ? "ghost" : b.danger ? "danger" : "", text: b.label, onclick: () => close(b.value === undefined ? true : typeof b.value === "function" ? b.value() : b.value) }));
89
+ $("modal").hidden = false;
90
+ const first = $("modal-body").querySelector("input,textarea");
91
+ (first || bar.lastChild).focus();
92
+ });
93
+ }
94
+ const askText = async (title, label, value) => {
95
+ const input = el("input", { type: "text", value: value || "" });
96
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") $("modal-buttons").lastChild.click(); });
97
+ return modal(title, [el("p", { class: "muted small", text: label }), input], [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.ok"), value: () => input.value.trim() || null }]);
98
+ };
99
+ const confirmBox = (title, text, yes, danger) => modal(title, el("p", { text }), [{ label: t("pj.no"), ghost: true, value: false }, { label: yes, danger, value: true }]);
100
+
101
+ // ---- state ------------------------------------------------------------------------------------
102
+ let monaco = null, editor = null, diffEditor = null;
103
+ let folder = "";
104
+ const tabs = []; // { path, model, mtime, saved, view, binary, secret }
105
+ let active = null;
106
+ let models = [];
107
+ let session = null; // the agent shown on the right
108
+ let cursor = 0;
109
+ let review = null; // { kind, path, onAccept, onReject }
110
+ let terminal = null;
111
+
112
+ // ---- the terminal is another part of the app, which plugs in here -----------------------------
113
+ /**
114
+ * Terminal hook. The terminal registers itself with BitPullIDE.terminal.register(impl), where impl has:
115
+ * mount(container, { token, folder, lang, t }) draws itself into the bottom panel, once;
116
+ * open() shows it and focuses it; close() hides it; newTab() opens another shell in the folder;
117
+ * runVisible(command) types a command in a visible tab, for the person to see (it still runs only on
118
+ * this computer, in this page; models never reach it).
119
+ * folderChanged(folder) optional: the project's folder changed.
120
+ */
121
+ window.BitPullIDE = {
122
+ terminal: {
123
+ register(impl) {
124
+ terminal = impl;
125
+ clear($("terminal-host"));
126
+ impl.mount($("terminal-host"), { token, folder, lang, t });
127
+ // The terminal tab may already be the one showing: then it starts at once, as if it had been opened.
128
+ if (sizes.bottomOpen && document.querySelector('[data-bottom="terminal"]').classList.contains("on")) impl.open();
129
+ },
130
+ },
131
+ get folder() { return folder; },
132
+ };
133
+ function showTerminal() {
134
+ showBottom("terminal");
135
+ if (terminal) terminal.open();
136
+ else clear($("terminal-host")).append(el("p", { class: "muted", text: t("ide.terminal.missing") }));
137
+ }
138
+
139
+ // ---- layout: panels that can be resized, and are remembered --------------------------------------
140
+ const sizes = Object.assign({ side: 260, agent: 360, bottom: 200, sideOpen: true, bottomOpen: true }, store.get("sizes", {}));
141
+ function applySizes() {
142
+ const root = document.documentElement.style;
143
+ root.setProperty("--side", `${sizes.sideOpen ? sizes.side : 0}px`);
144
+ root.setProperty("--agent", `${sizes.agent}px`);
145
+ root.setProperty("--bottom", `${sizes.bottom}px`);
146
+ $("side").hidden = !sizes.sideOpen;
147
+ $("bottom").hidden = !sizes.bottomOpen;
148
+ store.set("sizes", sizes);
149
+ if (editor) editor.layout();
150
+ if (diffEditor) diffEditor.layout();
151
+ }
152
+ for (const s of document.querySelectorAll(".split")) {
153
+ s.addEventListener("mousedown", (e) => {
154
+ e.preventDefault();
155
+ const which = s.dataset.split;
156
+ const start = { x: e.clientX, y: e.clientY, v: sizes[which] };
157
+ const rtl = document.documentElement.dir === "rtl";
158
+ const move = (m) => {
159
+ const dx = (m.clientX - start.x) * (rtl ? -1 : 1);
160
+ if (which === "side") sizes.side = Math.min(600, Math.max(160, start.v + dx));
161
+ if (which === "agent") sizes.agent = Math.min(800, Math.max(260, start.v - dx));
162
+ if (which === "bottom") sizes.bottom = Math.min(innerHeight - 160, Math.max(80, start.v - (m.clientY - start.y)));
163
+ applySizes();
164
+ };
165
+ const up = () => { removeEventListener("mousemove", move); removeEventListener("mouseup", up); };
166
+ addEventListener("mousemove", move);
167
+ addEventListener("mouseup", up);
168
+ });
169
+ }
170
+ function showSide(name) {
171
+ sizes.sideOpen = true;
172
+ for (const b of document.querySelectorAll("#activity button")) b.classList.toggle("on", b.dataset.side === name);
173
+ for (const p of document.querySelectorAll("#side section")) p.hidden = p.dataset.panel !== name;
174
+ applySizes();
175
+ if (name === "git") gitRefresh();
176
+ if (name === "outline") renderOutline();
177
+ if (name === "history") renderHistory();
178
+ if (name === "search") $("search-q").focus();
179
+ }
180
+ for (const b of document.querySelectorAll("#activity button")) b.addEventListener("click", () => {
181
+ if (b.classList.contains("on") && sizes.sideOpen) { sizes.sideOpen = false; applySizes(); } else showSide(b.dataset.side);
182
+ });
183
+ function showBottom(name) {
184
+ sizes.bottomOpen = true;
185
+ for (const b of document.querySelectorAll("[data-bottom]")) b.classList.toggle("on", b.dataset.bottom === name);
186
+ for (const p of document.querySelectorAll("[data-bpanel]")) p.hidden = p.dataset.bpanel !== name;
187
+ applySizes();
188
+ }
189
+ for (const b of document.querySelectorAll("[data-bottom]")) b.addEventListener("click", () => (b.dataset.bottom === "terminal" ? showTerminal() : showBottom(b.dataset.bottom)));
190
+ $("bottom-close").addEventListener("click", () => { sizes.bottomOpen = false; applySizes(); if (terminal) terminal.close(); });
191
+ const output = (line) => { const o = $("output"); o.textContent += `${line}\n`; if (o.textContent.length > 400_000) o.textContent = o.textContent.slice(-300_000); o.scrollTop = o.scrollHeight; };
192
+
193
+ // ---- Monaco, served by the app itself -------------------------------------------------------------
194
+ const MONACO_LANG = { de: "de", es: "es", fr: "fr", it: "it", ru: "ru", zh: "zh-cn", pt: "pt-br" };
195
+ function loadMonaco() {
196
+ return new Promise((resolve, reject) => {
197
+ const s = document.createElement("script");
198
+ s.src = "/ui/monaco/vs/loader.js";
199
+ s.onerror = () => reject(new Error("editor_missing"));
200
+ s.onload = () => {
201
+ window.require.config({ paths: { vs: "/ui/monaco/vs" }, "vs/nls": { availableLanguages: { "*": MONACO_LANG[lang] || "en" } } });
202
+ // This version of the loader does not always call back. The global appears early, as soon as the
203
+ // core is loaded; the editor is complete (stylesheet, languages, all its services) only when the
204
+ // main module has run, which registers the JSON language: creating an editor before fails.
205
+ window.require(["vs/editor/editor.main"], () => {}, reject);
206
+ // With the editor translated (Italian, German…) the JSON language never shows up as a global: there the
207
+ // sign is that the languages are registered and have stayed so for a moment.
208
+ const started = Date.now();
209
+ let langsSince = 0;
210
+ const wait = setInterval(() => {
211
+ const m = window.monaco;
212
+ const langs = m && m.editor && m.languages && m.languages.getLanguages ? m.languages.getLanguages().length : 0;
213
+ if (langs > 50 && !langsSince) langsSince = Date.now();
214
+ if (m && m.editor && m.languages && (m.languages.json || (langsSince && Date.now() - langsSince > 1500))) { clearInterval(wait); resolve(m); }
215
+ else if (Date.now() - started > 30000) { clearInterval(wait); reject(new Error("editor_missing")); }
216
+ }, 50);
217
+ };
218
+ document.body.appendChild(s);
219
+ });
220
+ }
221
+ const dark = () => matchMedia("(prefers-color-scheme: dark)").matches;
222
+ function setupEditor() {
223
+ const opts = { automaticLayout: true, theme: dark() ? "vs-dark" : "vs", minimap: { enabled: true }, fontSize: 13, tabSize: 2, renderWhitespace: "selection", smoothScrolling: true, scrollBeyondLastLine: false };
224
+ editor = monaco.editor.create($("editor"), { ...opts, model: null });
225
+ diffEditor = monaco.editor.createDiffEditor($("diff"), { ...opts, renderSideBySide: true, originalEditable: false });
226
+ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => monaco.editor.setTheme(dark() ? "vs-dark" : "vs"));
227
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive());
228
+ editor.addAction({ id: "bitpull.inline", label: t("ide.inline"), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], contextMenuGroupId: "1_modification", run: () => inlineEdit() });
229
+ editor.onDidChangeModelContent(() => { renderTabs(); clearTimeout(outlineTimer); outlineTimer = setTimeout(renderOutline, 600); });
230
+ editor.onDidChangeCursorPosition(() => markOutline());
231
+ }
232
+ let outlineTimer = null;
233
+
234
+ // ---- files --------------------------------------------------------------------------------------
235
+ const opened = new Set(["."]);
236
+ async function loadTree(path, into) {
237
+ const r = await api("/api/ws/tree", { folder, path });
238
+ clear(into);
239
+ for (const e of r.entries) {
240
+ const li = el("li", { role: "treeitem" });
241
+ const item = el("div", { class: "item", title: e.path },
242
+ el("span", { text: e.dir ? (opened.has(e.path) ? "▾" : "▸") : e.secret ? "🔒" : "·", class: "muted" }),
243
+ el("span", { class: "name", text: e.name }),
244
+ el("span", { class: "acts" },
245
+ el("button", { class: "icon", "data-tip": "tip.ide.rename", text: "✎", onclick: (ev) => { ev.stopPropagation(); renamePath(e.path); } }),
246
+ el("button", { class: "icon", "data-tip": "tip.ide.delete", text: "🗑", onclick: (ev) => { ev.stopPropagation(); deletePath(e.path, e.dir); } })));
247
+ if (active && active.path === e.path) item.classList.add("on");
248
+ li.append(item);
249
+ if (e.dir) {
250
+ const sub = el("ul", { role: "group" });
251
+ li.append(sub);
252
+ if (opened.has(e.path)) loadTree(e.path, sub).catch(() => {});
253
+ item.addEventListener("click", () => {
254
+ if (opened.has(e.path)) { opened.delete(e.path); clear(sub); } else { opened.add(e.path); loadTree(e.path, sub).catch((err) => output(explain(err))); }
255
+ item.firstChild.textContent = opened.has(e.path) ? "▾" : "▸";
256
+ });
257
+ } else item.addEventListener("click", () => openFile(e.path));
258
+ into.append(li);
259
+ }
260
+ }
261
+ const refreshTree = () => folder && loadTree(".", $("tree")).catch((err) => output(explain(err)));
262
+ async function newPath(dir) {
263
+ const name = await askText(t(dir ? "ide.new_dir" : "ide.new_file"), t("ide.new.hint"), active && !dir ? active.path.replace(/[^/]*$/, "") : "");
264
+ if (!name) return;
265
+ try { await api("/api/ws/create", { folder, path: name, dir }); refreshTree(); if (!dir) openFile(name); } catch (err) { output(explain(err)); }
266
+ }
267
+ async function renamePath(path) {
268
+ const to = await askText(t("ide.rename"), path, path);
269
+ if (!to || to === path) return;
270
+ try {
271
+ await api("/api/ws/rename", { folder, from: path, to });
272
+ const tab = tabs.find((x) => x.path === path);
273
+ if (tab) closeTab(tab, true);
274
+ refreshTree();
275
+ } catch (err) { output(explain(err)); }
276
+ }
277
+ async function deletePath(path, dir) {
278
+ if (!(await confirmBox(t("ide.delete"), t(dir ? "ide.delete.dir" : "ide.delete.file", { path }), t("ide.delete"), true))) return;
279
+ try { await api("/api/ws/delete", { folder, path }); const tab = tabs.find((x) => x.path === path); if (tab) closeTab(tab, true); refreshTree(); } catch (err) { output(explain(err)); }
280
+ }
281
+ $("new-file").addEventListener("click", () => newPath(false));
282
+ $("new-dir").addEventListener("click", () => newPath(true));
283
+ $("refresh").addEventListener("click", refreshTree);
284
+
285
+ // ---- tabs and the editor --------------------------------------------------------------------------
286
+ const isDirty = (tab) => tab.model && tab.model.getAlternativeVersionId() !== tab.saved;
287
+ function renderTabs() {
288
+ const bar = clear($("tabs"));
289
+ for (const tab of tabs) {
290
+ const name = tab.path.split("/").pop();
291
+ bar.append(el("div", { class: `tab${tab === active ? " on" : ""}`, role: "tab", title: tab.path, onclick: () => activate(tab) },
292
+ el("span", { text: name }), isDirty(tab) ? el("span", { class: "dirty", text: "●" }) : null,
293
+ el("button", { "data-tip": "tip.ide.close", text: "×", onclick: (e) => { e.stopPropagation(); closeTab(tab); } })));
294
+ }
295
+ $("welcome").hidden = !!active || !!review;
296
+ $("editor").hidden = !active || !!review;
297
+ }
298
+ async function openFile(path, line) {
299
+ if (!monaco) return;
300
+ let tab = tabs.find((x) => x.path === path);
301
+ if (!tab) {
302
+ let r;
303
+ try { r = await api("/api/ws/read", { folder, path }); } catch (err) { output(explain(err)); return; }
304
+ if (r.binary) { output(t("ide.binary", { path })); return; }
305
+ const uri = monaco.Uri.from({ scheme: "file", path: `/${path}` });
306
+ const model = monaco.editor.getModel(uri) || monaco.editor.createModel(r.text, undefined, uri);
307
+ tab = { path, model, mtime: r.mtime, saved: model.getAlternativeVersionId(), secret: r.secret };
308
+ tabs.push(tab);
309
+ if (r.secret) output(t("ide.secret_open", { path }));
310
+ }
311
+ activate(tab);
312
+ if (line) { editor.revealLineInCenter(line); editor.setPosition({ lineNumber: line, column: 1 }); editor.focus(); }
313
+ }
314
+ function activate(tab) {
315
+ if (active && active.model) active.view = editor.saveViewState();
316
+ active = tab;
317
+ closeReview();
318
+ editor.setModel(tab.model);
319
+ if (tab.view) editor.restoreViewState(tab.view);
320
+ renderTabs();
321
+ renderOutline();
322
+ if (!$("side").hidden && !document.querySelector('#side section[data-panel="history"]').hidden) renderHistory();
323
+ for (const n of document.querySelectorAll("#tree .item")) n.classList.toggle("on", n.title === tab.path);
324
+ editor.focus();
325
+ }
326
+ async function closeTab(tab, force) {
327
+ if (!force && isDirty(tab) && !(await confirmBox(t("ide.unsaved"), t("ide.unsaved.text", { path: tab.path }), t("ide.discard"), true))) return;
328
+ tabs.splice(tabs.indexOf(tab), 1);
329
+ if (active === tab) { active = null; editor.setModel(null); if (tabs.length) activate(tabs[tabs.length - 1]); }
330
+ tab.model.dispose();
331
+ renderTabs();
332
+ }
333
+ async function saveActive(force) {
334
+ const tab = active;
335
+ if (!tab) return;
336
+ try {
337
+ const r = await api("/api/ws/write", { folder, path: tab.path, content: tab.model.getValue(), mtime: tab.mtime, force: !!force });
338
+ tab.mtime = r.mtime;
339
+ tab.saved = tab.model.getAlternativeVersionId();
340
+ renderTabs();
341
+ } catch (err) {
342
+ if (String(err.message) !== "file_changed_on_disk") return output(explain(err));
343
+ const choice = await modal(t("ide.conflict"), el("p", { text: t("ide.conflict.text", { path: tab.path }) }), [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.conflict.reload"), ghost: true, value: "reload" }, { label: t("ide.conflict.overwrite"), danger: true, value: "overwrite" }]);
344
+ if (choice === "overwrite") return saveActive(true);
345
+ if (choice === "reload") reloadTab(tab);
346
+ }
347
+ }
348
+ async function reloadTab(tab) {
349
+ const r = await api("/api/ws/read", { folder, path: tab.path });
350
+ if (r.binary) return;
351
+ tab.model.setValue(r.text);
352
+ tab.mtime = r.mtime;
353
+ tab.saved = tab.model.getAlternativeVersionId();
354
+ renderTabs();
355
+ }
356
+
357
+ // ---- reviewing a change in the editor, piece by piece ---------------------------------------------
358
+ function closeReview() {
359
+ // The diff editor lets go of its models first, then they can go.
360
+ if (diffEditor) diffEditor.setModel(null);
361
+ if (review) { review.original.dispose(); review.modified.dispose(); }
362
+ review = null;
363
+ $("review").hidden = true;
364
+ $("diff").hidden = true;
365
+ renderTabs();
366
+ }
367
+ /**
368
+ * Shows `before` against `after` side by side. With pieces = true each changed piece can be put back
369
+ * one by one (it is changed in the right-hand side, which the person then keeps or saves).
370
+ */
371
+ function showReview({ title, before, after, path, pieces, accept, reject, acceptLabel, rejectLabel }) {
372
+ const lang = monaco.Uri.from({ scheme: "file", path: `/${path || "x.txt"}` });
373
+ const original = monaco.editor.createModel(before ?? "", undefined, monaco.Uri.from({ scheme: "bp-before", path: `/${Date.now()}/${path || "x"}` }));
374
+ const modified = monaco.editor.createModel(after ?? "", undefined, monaco.Uri.from({ scheme: "bp-after", path: `/${Date.now()}/${path || "x"}` }));
375
+ const language = monaco.editor.getModel(lang) ? monaco.editor.getModel(lang).getLanguageId() : null;
376
+ if (language) { monaco.editor.setModelLanguage(original, language); monaco.editor.setModelLanguage(modified, language); }
377
+ if (review) { diffEditor.setModel(null); review.original.dispose(); review.modified.dispose(); }
378
+ diffEditor.setModel({ original, modified });
379
+ review = { path, original, modified };
380
+ $("review").hidden = false;
381
+ $("diff").hidden = false;
382
+ $("editor").hidden = true;
383
+ $("welcome").hidden = true;
384
+ $("review-title").textContent = title;
385
+ $("review-accept").textContent = acceptLabel || t("ide.review.keep");
386
+ $("review-reject").textContent = rejectLabel || t("ide.review.revert_all");
387
+ $("review-accept").onclick = async () => { const keep = modified.getValue(); closeReview(); if (accept) await accept(keep); };
388
+ $("review-reject").onclick = async () => { closeReview(); if (reject) await reject(); };
389
+ const hunks = clear($("review-hunks"));
390
+ if (pieces) {
391
+ const draw = () => {
392
+ clear(hunks);
393
+ const changes = diffEditor.getLineChanges() || [];
394
+ changes.forEach((c, i) => hunks.append(el("button", { class: "ghost small", "data-tip": "tip.ide.review.piece", text: t("ide.review.piece", { n: i + 1 }), onclick: () => revertPiece(c, original, modified) })));
395
+ };
396
+ diffEditor.onDidUpdateDiff(draw);
397
+ }
398
+ }
399
+ /** Puts one changed piece back as it was. */
400
+ function revertPiece(c, original, modified) {
401
+ const origLines = c.originalEndLineNumber === 0 ? [] : original.getValue().split("\n").slice(c.originalStartLineNumber - 1, c.originalEndLineNumber);
402
+ let range;
403
+ if (c.modifiedEndLineNumber === 0) range = new monaco.Range(c.modifiedStartLineNumber + 1, 1, c.modifiedStartLineNumber + 1, 1);
404
+ else range = new monaco.Range(c.modifiedStartLineNumber, 1, c.modifiedEndLineNumber, modified.getLineMaxColumn(c.modifiedEndLineNumber));
405
+ let text = origLines.join("\n");
406
+ if (c.modifiedEndLineNumber === 0) text += "\n";
407
+ else if (!origLines.length) {
408
+ // The piece only added lines: remove them, with their line ending.
409
+ const end = c.modifiedEndLineNumber < modified.getLineCount() ? new monaco.Range(c.modifiedStartLineNumber, 1, c.modifiedEndLineNumber + 1, 1) : new monaco.Range(Math.max(1, c.modifiedStartLineNumber - 1), modified.getLineMaxColumn(Math.max(1, c.modifiedStartLineNumber - 1)), c.modifiedEndLineNumber, modified.getLineMaxColumn(c.modifiedEndLineNumber));
410
+ modified.pushEditOperations([], [{ range: end, text: "" }], () => null);
411
+ return;
412
+ }
413
+ modified.pushEditOperations([], [{ range, text }], () => null);
414
+ }
415
+ $("review-close").addEventListener("click", closeReview);
416
+
417
+ /** The agent's change to a file: compared with the file as it was before the agent started. */
418
+ async function reviewAgentChange(path) {
419
+ if (!session) return;
420
+ try {
421
+ const r = await api("/api/projects/original", { id: session.id, path });
422
+ showReview({
423
+ title: t("ide.review.agent", { path }), before: r.before ?? "", after: r.now ?? "", path, pieces: true,
424
+ acceptLabel: t("ide.review.keep"), rejectLabel: t("ide.review.revert_all"),
425
+ accept: async (text) => { if (text !== r.now) await writeFrom(path, text); },
426
+ reject: async () => { await writeFrom(path, r.before ?? ""); },
427
+ });
428
+ } catch (err) { output(explain(err)); }
429
+ }
430
+ async function writeFrom(path, text) {
431
+ try {
432
+ await api("/api/ws/write", { folder, path, content: text, force: true });
433
+ const tab = tabs.find((x) => x.path === path);
434
+ if (tab) await reloadTab(tab);
435
+ } catch (err) { output(explain(err)); }
436
+ }
437
+
438
+ // ---- quick open, search -----------------------------------------------------------------------
439
+ async function quickOpen() {
440
+ if (!folder) return;
441
+ const input = el("input", { type: "text", placeholder: t("ide.quick.placeholder") });
442
+ const list = el("ul", { class: "list" });
443
+ let results = [], sel = 0;
444
+ const draw = () => { clear(list); results.forEach((r, i) => list.append(el("li", { class: i === sel ? "on" : "", text: r.path, onclick: () => pick(r) }))); };
445
+ const pick = (r) => { $("modal").hidden = true; openFile(r.path); };
446
+ let timer = null;
447
+ input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(async () => { const q = input.value.trim(); if (!q) { results = []; draw(); return; } try { results = (await api("/api/ws/search", { folder, query: q, names: true })).results.slice(0, 50); sel = 0; draw(); } catch {} }, 150); });
448
+ input.addEventListener("keydown", (e) => {
449
+ if (e.key === "ArrowDown") { sel = Math.min(results.length - 1, sel + 1); draw(); e.preventDefault(); }
450
+ if (e.key === "ArrowUp") { sel = Math.max(0, sel - 1); draw(); e.preventDefault(); }
451
+ if (e.key === "Enter" && results[sel]) pick(results[sel]);
452
+ });
453
+ modal(t("ide.quick"), [input, list], [{ label: t("ide.close"), ghost: true, value: null }]);
454
+ }
455
+ $("quick-open").addEventListener("click", quickOpen);
456
+ let searchTimer = null;
457
+ async function runSearch() {
458
+ const q = $("search-q").value.trim();
459
+ const out = clear($("search-results"));
460
+ $("search-info").textContent = "";
461
+ if (!q || !folder) return;
462
+ try {
463
+ const r = await api("/api/ws/search", { folder, query: q, caseSensitive: $("search-case").checked, regex: $("search-regex").checked });
464
+ $("search-info").textContent = t(r.truncated ? "ide.search.many" : "ide.search.count", { n: r.results.length });
465
+ for (const hit of r.results) out.append(el("li", { title: `${hit.path}:${hit.line}`, onclick: () => openFile(hit.path, hit.line) }, el("span", { class: "badge", text: `${hit.path.split("/").pop()}:${hit.line}` }), el("span", { class: "name", text: hit.text.trim() })));
466
+ } catch (err) { $("search-info").textContent = explain(err); }
467
+ }
468
+ for (const id of ["search-q", "search-case", "search-regex"]) $(id).addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 250); });
469
+
470
+ // ---- git: the person's own actions -------------------------------------------------------------------
471
+ async function gitRefresh() {
472
+ if (!folder) return;
473
+ const staged = clear($("git-staged")), changes = clear($("git-changes"));
474
+ $("git-msg").textContent = "";
475
+ try {
476
+ const r = await api("/api/ws/git/status", { folder });
477
+ $("git-branch").textContent = r.branch ? t("ide.git.branch", { branch: r.branch }) : "";
478
+ for (const f of r.files) {
479
+ const row = (where, letter, stage) => el("li", { title: f.path, onclick: () => gitDiff(f.path, stage === false) },
480
+ el("span", { class: "badge", text: letter }), el("span", { class: "name", text: f.path }),
481
+ el("button", { class: "icon", "data-tip": stage ? "tip.ide.git.stage" : "tip.ide.git.unstage", text: stage ? "+" : "−", onclick: async (e) => { e.stopPropagation(); try { await api(stage ? "/api/ws/git/stage" : "/api/ws/git/unstage", { folder, paths: [f.path] }); gitRefresh(); } catch (err) { $("git-msg").textContent = explain(err); } } }));
482
+ if (f.staged) staged.append(row("staged", f.staged, false));
483
+ if (f.unstaged) changes.append(row("changes", f.unstaged === "?" ? "U" : f.unstaged, true));
484
+ }
485
+ if (!r.files.length) $("git-msg").textContent = t("ide.git.clean");
486
+ } catch (err) { $("git-branch").textContent = ""; $("git-msg").textContent = explain(err); }
487
+ }
488
+ async function gitDiff(path, staged) {
489
+ try {
490
+ const [d, now] = await Promise.all([api("/api/ws/git/diff", { folder, path, staged }), api("/api/ws/read", { folder, path }).catch(() => ({ text: "" }))]);
491
+ showReview({ title: t("ide.git.diff", { path }), before: d.base, after: now.text || "", path, pieces: false, acceptLabel: t("ide.close"), rejectLabel: t("ide.git.discard"),
492
+ reject: async () => { if (await confirmBox(t("ide.git.discard"), t("ide.git.discard.text", { path }), t("ide.git.discard"), true)) await writeFrom(path, d.base); gitRefresh(); } });
493
+ } catch (err) { $("git-msg").textContent = explain(err); }
494
+ }
495
+ $("git-refresh").addEventListener("click", gitRefresh);
496
+ $("git-commit").addEventListener("click", async () => {
497
+ try { const r = await api("/api/ws/git/commit", { folder, message: $("git-message").value }); $("git-message").value = ""; output(r.output); gitRefresh(); } catch (err) { $("git-msg").textContent = explain(err); }
498
+ });
499
+ $("git-push").addEventListener("click", async () => {
500
+ if (!(await confirmBox(t("ide.git.push"), t("ide.git.push.confirm"), t("ide.git.push")))) return;
501
+ try { const r = await api("/api/ws/git/push", { folder, confirmed: true }); output(r.output || t("ide.git.pushed")); } catch (err) { $("git-msg").textContent = explain(err); }
502
+ });
503
+
504
+ // ---- problems, outline, history, preview --------------------------------------------------------------
505
+ $("problems-run").addEventListener("click", async () => {
506
+ if (!folder) return;
507
+ const list = clear($("problems"));
508
+ showBottom("problems");
509
+ list.append(el("li", { class: "muted", text: t("ide.problems.running") }));
510
+ try {
511
+ const r = await api("/api/ws/problems", { folder });
512
+ clear(list);
513
+ if (!r.ran.length) list.append(el("li", { class: "muted", text: t("ide.problems.none_setup") }));
514
+ else if (!r.problems.length) list.append(el("li", { class: "muted", text: t("ide.problems.clean", { tools: r.ran.join(", ") }) }));
515
+ const byFile = {};
516
+ for (const p of r.problems) {
517
+ (byFile[p.file] = byFile[p.file] || []).push(p);
518
+ list.append(el("li", { title: p.message, onclick: () => openFile(p.file, p.line) }, el("span", { class: `badge ${p.severity === "error" ? "bad" : "warn"}`, text: p.source }), el("span", { class: "name", text: `${p.file}:${p.line} ${p.message}` })));
519
+ }
520
+ for (const m of monaco.editor.getModels()) {
521
+ if (m.uri.scheme !== "file") continue;
522
+ const ps = byFile[m.uri.path.slice(1)] || [];
523
+ monaco.editor.setModelMarkers(m, "bitpull", ps.map((p) => ({ startLineNumber: p.line, startColumn: p.column, endLineNumber: p.line, endColumn: p.column + 1, message: `${p.source}: ${p.message}`, severity: p.severity === "error" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning })));
524
+ }
525
+ } catch (err) { clear(list).append(el("li", { class: "muted", text: explain(err) })); }
526
+ });
527
+
528
+ /** A simple outline from the text: classes, functions and headings, for every language. */
529
+ const OUTLINE = [
530
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/,
531
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/,
532
+ /^\s*(?:async\s+)?def\s+(\w+)|^\s*class\s+(\w+)/,
533
+ /^\s*func\s+(?:\([^)]*\)\s*)?(\w+)|^\s*(?:pub\s+)?(?:fn|struct|enum|trait|impl)\s+(\w+)/,
534
+ /^\s*(?:public|private|protected|static|\s)+[\w<>[\],\s]+\s+(\w+)\s*\([^;]*$/,
535
+ /^(#{1,6})\s+(.+)$/,
536
+ ];
537
+ function renderOutline() {
538
+ const list = clear($("outline"));
539
+ if (!active || !active.model) return;
540
+ const lines = active.model.getValue().split("\n");
541
+ if (lines.length > 20000) return;
542
+ lines.forEach((text, i) => {
543
+ for (const re of OUTLINE) {
544
+ const m = re.exec(text);
545
+ if (!m) continue;
546
+ const name = re === OUTLINE[5] ? `${" ".repeat(m[1].length - 1)}${m[2]}` : m.slice(1).find(Boolean);
547
+ if (name) list.append(el("li", { "data-line": i + 1, onclick: () => openFile(active.path, i + 1) }, el("span", { class: "muted small", text: String(i + 1) }), el("span", { class: "name", text: name })));
548
+ break;
549
+ }
550
+ });
551
+ markOutline();
552
+ }
553
+ function markOutline() {
554
+ if (!editor || !editor.getPosition()) return;
555
+ const line = editor.getPosition().lineNumber;
556
+ let best = null;
557
+ for (const li of $("outline").children) if (Number(li.dataset.line) <= line) best = li;
558
+ for (const li of $("outline").children) li.classList.toggle("on", li === best);
559
+ }
560
+ async function renderHistory() {
561
+ const list = clear($("history"));
562
+ if (!active) return list.append(el("li", { class: "muted", text: t("ide.history.none") }));
563
+ try {
564
+ const r = await api("/api/ws/history", { folder, path: active.path });
565
+ if (!r.versions.length) list.append(el("li", { class: "muted", text: t("ide.history.none") }));
566
+ for (const v of r.versions) list.append(el("li", { onclick: () => showVersion(active.path, v.id) }, el("span", { class: "badge", text: t(`ide.history.${v.why}`) }), el("span", { class: "name", text: new Date(v.at).toLocaleString(lang) })));
567
+ } catch (err) { list.append(el("li", { class: "muted", text: explain(err) })); }
568
+ }
569
+ async function showVersion(path, id) {
570
+ try {
571
+ const v = await api("/api/ws/version", { folder, path, id });
572
+ const tab = tabs.find((x) => x.path === path);
573
+ const now = tab ? tab.model.getValue() : (await api("/api/ws/read", { folder, path })).text;
574
+ showReview({ title: t("ide.history.compare", { path }), before: v.text, after: now, path, pieces: false, acceptLabel: t("ide.close"), rejectLabel: t("ide.history.restore"),
575
+ reject: async () => { if (tab) { tab.model.setValue(v.text); activate(tab); } else await writeFrom(path, v.text); } });
576
+ } catch (err) { output(explain(err)); }
577
+ }
578
+ $("preview").addEventListener("click", async () => {
579
+ const url = await askText(t("ide.preview"), t("ide.preview.hint"), store.get("preview", "http://localhost:3000"));
580
+ if (!url) return;
581
+ store.set("preview", url);
582
+ try { await api("/api/ws/preview", { url }); } catch (err) { output(explain(err)); }
583
+ });
584
+
585
+ // ---- models, folder, mode, limits ---------------------------------------------------------------------
586
+ async function loadModels() {
587
+ const r = await api("/api/chat/models");
588
+ models = [
589
+ ...r.own.map((m) => ({ ...m, key: `own:${m.model}`, label: m.name, own: true, paid: false })),
590
+ ...r.network.map((m) => ({ ...m, key: `net:${m.model}`, label: m.name, own: false, paid: false })),
591
+ ...r.paid.map((m) => ({ ...m, model: m.id, key: `paid:${m.id}`, label: m.name, own: false, paid: true })),
592
+ ].filter((m) => (m.tasks || ["chat"]).includes("chat") || m.paid);
593
+ const sel = clear($("model"));
594
+ const groups = [["chat.group.own", models.filter((m) => m.own)], ["chat.group.network", models.filter((m) => !m.own && !m.paid)], ["chat.group.paid", models.filter((m) => m.paid)]];
595
+ for (const [label, list] of groups) {
596
+ if (!list.length) continue;
597
+ const g = el("optgroup", { label: t(label) });
598
+ for (const m of list) g.append(el("option", { value: m.key, disabled: !m.available, text: m.available ? m.label : `${m.label} · ${t("ide.model.unavailable")}` }));
599
+ sel.append(g);
600
+ }
601
+ if (!models.length) sel.append(el("option", { value: "", text: t("ide.model.none") }));
602
+ const keep = store.get("model", "");
603
+ const first = models.find((m) => m.available && m.key === keep) || models.find((m) => m.available && m.own) || models.find((m) => m.available);
604
+ if (first) sel.value = first.key;
605
+ }
606
+ const chosenModel = () => models.find((m) => m.key === $("model").value && m.available) || null;
607
+ $("model").addEventListener("change", () => store.set("model", $("model").value));
608
+ async function loadFolders() {
609
+ const r = await api("/api/projects");
610
+ const sel = clear($("folder"));
611
+ for (const f of r.folders) sel.append(el("option", { value: f, text: f }));
612
+ if (!r.folders.length) sel.append(el("option", { value: "", text: t("ide.folder.none") }));
613
+ const want = BP.folder || store.get("folder", "");
614
+ sel.value = r.folders.includes(want) ? want : r.folders[0] || "";
615
+ return r;
616
+ }
617
+ async function setFolder(f) {
618
+ if (tabs.some(isDirty) && !(await confirmBox(t("ide.unsaved"), t("ide.unsaved.all"), t("ide.discard"), true))) { $("folder").value = folder; return; }
619
+ for (const tab of [...tabs]) closeTab(tab, true);
620
+ folder = f;
621
+ store.set("folder", f);
622
+ session = null;
623
+ cursor = 0;
624
+ clear($("agent-log"));
625
+ opened.clear();
626
+ opened.add(".");
627
+ refreshTree();
628
+ if (terminal && terminal.folderChanged) terminal.folderChanged(f);
629
+ await refreshAgents();
630
+ }
631
+ $("folder").addEventListener("change", () => setFolder($("folder").value));
632
+ $("folder-add").addEventListener("click", async () => {
633
+ const path = await askText(t("pj.folder.add"), t("pj.folder.confirm"), "");
634
+ if (!path) return;
635
+ try { await api("/api/folders", { path }); await loadFolders(); $("folder").value = (await api("/api/projects")).folders.slice(-1)[0]; setFolder($("folder").value); } catch (err) { output(explain(err)); }
636
+ });
637
+ const MODES = ["ask", "edits", "auto"];
638
+ function renderModeHint() { $("mode-hint").textContent = t(`pj.mode.${$("mode").value}.hint`); }
639
+ async function setMode(mode) {
640
+ const prev = $("mode").value;
641
+ $("mode").value = mode;
642
+ renderModeHint();
643
+ if (!session) return;
644
+ try { await api("/api/projects/mode", { id: session.id, mode, remoteConfirmed: mode === "auto" && session.leaves ? await remoteOk(true) : undefined }); }
645
+ catch (err) { $("mode").value = prev; renderModeHint(); output(explain(err)); }
646
+ }
647
+ $("mode").addEventListener("change", () => setMode($("mode").value));
648
+ /** Content that leaves this computer: the person says yes once, knowing it. */
649
+ async function remoteOk(auto) {
650
+ return !!(await confirmBox(t("ide.remote.title"), t(auto ? "pj.auto.confirm" : "pj.remote"), t("pj.yes")));
651
+ }
652
+ const limitsExplained = () => el("dl", {}, el("dt", { text: t("ide.limits.why.title") }), el("dd", { text: t("ide.limits.why") }), el("dt", { text: t("ide.limits.not.title") }), el("dd", { text: t("ide.limits.not") }), el("dt", { text: t("ide.limits.auto.title") }), el("dd", { text: t("ide.limits.auto") }));
653
+ async function editLimits() {
654
+ // Before an agent exists there is nothing to change yet, but the explanation is useful already.
655
+ if (!session) {
656
+ await modal(t("pj.limits"), [el("p", { text: t("ide.limits.intro") }), ...["steps", "minutes", "credits"].map((k) => el("p", { class: "muted small", text: t(`ide.limits.${k}.what`) })), limitsExplained(), el("p", { class: "muted small", text: t("ide.agent.none") })], [{ label: t("ide.ok"), value: null }]);
657
+ return;
658
+ }
659
+ const field = (k) => {
660
+ const input = el("input", { type: "number", min: k === "credits" ? 0 : 1, step: "1", value: session.limits[k] });
661
+ return { k, input, block: el("div", {}, el("label", { class: "row" }, el("strong", { text: t(`pj.limit.${k}`) }), input, el("span", { class: "muted small", text: t("ide.limits.auto_value", { n: session.autoLimits[k] }) })), el("p", { class: "muted small", text: t(`ide.limits.${k}.what`) })) };
662
+ };
663
+ const fields = ["steps", "minutes", "credits"].map(field);
664
+ const body = [
665
+ el("p", { text: t("ide.limits.intro") }), ...fields.map((f) => f.block),
666
+ limitsExplained(),
667
+ ];
668
+ const choice = await modal(t("pj.limits"), body, [{ label: t("pj.no"), ghost: true, value: null }, { label: t("ide.limits.reset"), ghost: true, value: "auto" }, { label: t("pj.limits.save"), value: "save" }]);
669
+ try {
670
+ if (choice === "auto") session = await api("/api/projects/limits", { id: session.id, auto: true });
671
+ if (choice === "save") session = await api("/api/projects/limits", { id: session.id, limits: Object.fromEntries(fields.map((f) => [f.k, Number(f.input.value)])) });
672
+ renderAgent(session);
673
+ } catch (err) { output(explain(err)); }
674
+ }
675
+ $("limits-open").addEventListener("click", editLimits);
676
+ $("limit-edit").addEventListener("click", editLimits);
677
+
678
+ // ---- agents ---------------------------------------------------------------------------------------
679
+ let agentList = [];
680
+ async function refreshAgents() {
681
+ const r = await api("/api/projects");
682
+ agentList = (r.sessions || []).filter((s) => s.folder === folder).sort((x, y) => y.updated - x.updated);
683
+ drawAgents();
684
+ if (!session && agentList.length) selectAgent(agentList[0].id);
685
+ $("agents-toggle").textContent = session ? session.title || t("ide.agent.untitled") : t("ide.agents");
686
+ }
687
+ function drawAgents() {
688
+ const q = $("agents-q").value.trim().toLowerCase();
689
+ const list = clear($("agents-list"));
690
+ for (const a of agentList.filter((x) => !q || (x.title || "").toLowerCase().includes(q))) {
691
+ list.append(el("li", { class: session && a.id === session.id ? "on" : "", onclick: () => { selectAgent(a.id); $("agents").hidden = true; } },
692
+ el("span", { class: "badge", text: t(`pj.status.${a.status}`).replace(/[.…]$/, "") }), el("span", { class: "name", text: a.title || t("ide.agent.untitled") })));
693
+ }
694
+ if (!agentList.length) list.append(el("li", { class: "muted", text: t("ide.agent.none") }));
695
+ }
696
+ $("agents-toggle").addEventListener("click", () => { $("agents").hidden = !$("agents").hidden; drawAgents(); });
697
+ $("agents-q").addEventListener("input", drawAgents);
698
+ async function selectAgent(id) {
699
+ session = await api("/api/projects/state", { id, after: 0 });
700
+ cursor = 0;
701
+ clear($("agent-log"));
702
+ $("mode").value = session.mode;
703
+ renderModeHint();
704
+ renderAgent(session);
705
+ }
706
+ async function newAgent() {
707
+ const m = chosenModel();
708
+ if (!folder) return output(t("pj.err.project_folder_not_authorised"));
709
+ if (!m) return output(t("pj.err.project_needs_model"));
710
+ const leaves = !m.own;
711
+ let remoteConfirmed;
712
+ if (leaves) { remoteConfirmed = await remoteOk($("mode").value === "auto"); if (!remoteConfirmed) return null; }
713
+ try {
714
+ session = await api("/api/projects/open", { folder, model: m.model, modelName: m.label, paid: m.paid, placement: m.own ? "mine" : "auto", mode: $("mode").value, remoteConfirmed });
715
+ cursor = 0;
716
+ clear($("agent-log"));
717
+ renderAgent(session);
718
+ await refreshAgents();
719
+ $("prompt").focus();
720
+ return session;
721
+ } catch (err) { output(explain(err)); return null; }
722
+ }
723
+ $("agent-new").addEventListener("click", newAgent);
724
+
725
+ const STATUS_BUSY = new Set(["running", "waiting", "paused", "limit"]);
726
+ function renderAgent(v) {
727
+ if (!v) return;
728
+ session = { ...session, ...v };
729
+ const log = $("agent-log");
730
+ for (const e of v.events || []) {
731
+ if (e.n < cursor) continue;
732
+ cursor = e.n + 1;
733
+ log.append(eventNode(e));
734
+ if (e.kind === "output") { output(e.text); if (terminal && terminal.agentOutput) terminal.agentOutput(e.text); }
735
+ if (e.kind === "action" && e.action === "run") { output(`$ ${e.text}`); if (terminal && terminal.runVisible) terminal.runVisible(e.text); }
736
+ if (e.kind === "notice" && (e.code === "command_ended" || e.code === "command_timeout") && terminal && terminal.agentDone) terminal.agentDone(e.text);
737
+ }
738
+ log.scrollTop = log.scrollHeight;
739
+ $("agent-status").textContent = `${t(`pj.status.${v.status}`)} · ${t("pj.used", { steps: v.used.steps, minutes: v.used.minutes, credits: Math.round(v.used.credits * 100) / 100 })} · ${t("ide.limits.short", { steps: v.limits.steps, minutes: v.limits.minutes, credits: v.limits.credits || "—" })}`;
740
+ $("agent-stop").hidden = !STATUS_BUSY.has(v.status);
741
+ $("agent-send").hidden = STATUS_BUSY.has(v.status);
742
+ $("agent-undo").hidden = !v.canUndo;
743
+ $("agent-limit").hidden = v.status !== "limit";
744
+ if (v.status === "limit") $("agent-limit-text").textContent = t("ide.limit.text", { steps: v.limits.steps, minutes: v.limits.minutes, credits: v.limits.credits || "—" });
745
+ renderPending(v.pending);
746
+ const ch = clear($("agent-changes"));
747
+ if (v.changes && v.changes.length) {
748
+ ch.append(el("div", { class: "panel-sub", text: t("pj.tab.changes") }));
749
+ for (const c of v.changes) ch.append(el("div", {}, el("a", { href: "#", text: c.path, onclick: (e) => { e.preventDefault(); reviewAgentChange(c.path); } })));
750
+ }
751
+ $("agents-toggle").textContent = v.title || t("ide.agent.untitled");
752
+ for (const tab of tabs) if (!isDirty(tab) && v.changes && v.changes.some((c) => c.path === tab.path)) reloadTab(tab).catch(() => {});
753
+ }
754
+ function eventNode(e) {
755
+ if (e.kind === "user") return el("div", { class: "ev user", text: e.text });
756
+ if (e.kind === "assistant") return el("div", { class: "ev", text: e.text });
757
+ if (e.kind === "output") return el("div", { class: "ev output", text: e.text });
758
+ if (e.kind === "action") return el("div", { class: "ev action", text: `${t(`pj.act.${e.action}`)} ${e.path || e.text || ""}` });
759
+ if (e.kind === "error") return el("div", { class: "ev error", text: `${explain(e.code)}${e.text ? `\n${e.text}` : ""}` });
760
+ const key = `pj.note.${e.code}`;
761
+ return el("div", { class: "ev notice", text: t(key, { code: e.text }) });
762
+ }
763
+ let shownPending = null;
764
+ function renderPending(p) {
765
+ const box = $("agent-pending");
766
+ if (!p) { box.hidden = true; shownPending = null; return; }
767
+ if (shownPending === p.id) return;
768
+ shownPending = p.id;
769
+ clear(box);
770
+ box.hidden = false;
771
+ const answer = async (approved) => { try { renderAgent(await api("/api/projects/approve", { id: session.id, action: p.id, approved })); } catch (err) { output(explain(err)); } closeReview(); };
772
+ if (p.type === "write") {
773
+ box.append(el("strong", { text: t("pj.ask.write", { path: p.path }) }));
774
+ box.append(el("div", { class: "row" }, el("button", { class: "ghost", "data-tip": "tip.ide.review.open", text: t("ide.review.open"), onclick: () => showReview({ title: t("pj.ask.write", { path: p.path }), before: p.before, after: p.after, path: p.path, pieces: false, acceptLabel: t("pj.approve"), rejectLabel: t("pj.refuse"), accept: () => answer(true), reject: () => answer(false) }) })));
775
+ if (monaco && p.before !== undefined) showReview({ title: t("pj.ask.write", { path: p.path }), before: p.before, after: p.after, path: p.path, pieces: false, acceptLabel: t("pj.approve"), rejectLabel: t("pj.refuse"), accept: () => answer(true), reject: () => answer(false) });
776
+ } else {
777
+ box.append(el("strong", { text: t("pj.ask.command") }), el("code", { text: p.command }), el("p", { class: "muted small", text: `${t("pj.ask.cwd", { cwd: p.cwd })} · ${t("pj.where.computer")}` }));
778
+ if (p.risk) box.append(el("p", { class: "small", text: t(`pj.risk.${p.risk}`) }));
779
+ }
780
+ box.append(el("div", { class: "row" }, el("button", { "data-tip": "tip.pj.approve", text: t("pj.approve"), onclick: () => answer(true) }), el("button", { class: "ghost", "data-tip": "tip.pj.refuse", text: t("pj.refuse"), onclick: () => answer(false) })));
781
+ }
782
+
783
+ /** @-mentions: typing @ suggests files of the folder. */
784
+ let mentionTimer = null, mentionSel = 0, mentionItems = [];
785
+ function mentionQuery() {
786
+ const ta = $("prompt");
787
+ const before = ta.value.slice(0, ta.selectionStart);
788
+ const m = /(?:^|\s)@([^\s@]*)$/.exec(before);
789
+ return m ? m[1] : null;
790
+ }
791
+ function drawMentions() {
792
+ const box = clear($("mention"));
793
+ box.hidden = !mentionItems.length;
794
+ mentionItems.forEach((p, i) => box.append(el("li", { class: i === mentionSel ? "on" : "", text: p, onmousedown: (e) => { e.preventDefault(); insertMention(p); } })));
795
+ }
796
+ function insertMention(path) {
797
+ const ta = $("prompt");
798
+ const before = ta.value.slice(0, ta.selectionStart).replace(/@([^\s@]*)$/, `@${path} `);
799
+ ta.value = before + ta.value.slice(ta.selectionStart);
800
+ ta.selectionStart = ta.selectionEnd = before.length;
801
+ mentionItems = [];
802
+ drawMentions();
803
+ ta.focus();
804
+ }
805
+ $("prompt").addEventListener("input", () => {
806
+ const q = mentionQuery();
807
+ clearTimeout(mentionTimer);
808
+ if (q === null || !folder) { mentionItems = []; drawMentions(); return; }
809
+ mentionTimer = setTimeout(async () => {
810
+ try { mentionItems = q ? (await api("/api/ws/search", { folder, query: q, names: true })).results.slice(0, 12).map((r) => r.path) : tabs.map((x) => x.path).slice(0, 12); } catch { mentionItems = []; }
811
+ mentionSel = 0;
812
+ drawMentions();
813
+ }, 120);
814
+ });
815
+ $("prompt").addEventListener("keydown", (e) => {
816
+ if (mentionItems.length) {
817
+ if (e.key === "ArrowDown") { mentionSel = Math.min(mentionItems.length - 1, mentionSel + 1); drawMentions(); e.preventDefault(); return; }
818
+ if (e.key === "ArrowUp") { mentionSel = Math.max(0, mentionSel - 1); drawMentions(); e.preventDefault(); return; }
819
+ if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(mentionItems[mentionSel]); return; }
820
+ if (e.key === "Escape") { mentionItems = []; drawMentions(); return; }
821
+ }
822
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
823
+ });
824
+ async function send() {
825
+ const text = $("prompt").value.trim();
826
+ if (!text) return;
827
+ if (!session && !(await newAgent())) return;
828
+ try {
829
+ $("prompt").value = "";
830
+ renderAgent(await api("/api/projects/send", { id: session.id, text }));
831
+ } catch (err) { $("prompt").value = text; output(explain(err)); }
832
+ }
833
+ $("agent-send").addEventListener("click", send);
834
+ $("agent-stop").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/stop", { id: session.id })); } catch (err) { output(explain(err)); } });
835
+ $("limit-stop").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/stop", { id: session.id })); } catch (err) { output(explain(err)); } });
836
+ $("limit-go").addEventListener("click", async () => { try { renderAgent(await api("/api/projects/continue", { id: session.id })); } catch (err) { output(explain(err)); } });
837
+ $("agent-undo").addEventListener("click", async () => {
838
+ if (!(await confirmBox(t("pj.undo"), t("ide.undo.confirm"), t("pj.undo"), true))) return;
839
+ try { renderAgent(await api("/api/projects/undo", { id: session.id })); for (const tab of tabs) if (!isDirty(tab)) reloadTab(tab).catch(() => {}); refreshTree(); } catch (err) { output(explain(err)); }
840
+ });
841
+ // The app works only while a page asks for news: this is also how it knows someone is watching.
842
+ setInterval(async () => {
843
+ if (!session || document.hidden) return;
844
+ try { renderAgent(await api("/api/projects/state", { id: session.id, after: cursor })); } catch (err) { if (/project_not_found/.test(err.message)) { session = null; refreshAgents(); } }
845
+ }, 800);
846
+
847
+ /** Rewrites the selected text with the agent's model, shown as a change to accept. */
848
+ async function inlineEdit() {
849
+ if (!active) return;
850
+ const sel = editor.getSelection();
851
+ const model = active.model;
852
+ const range = sel && !sel.isEmpty() ? sel : new monaco.Range(sel.startLineNumber, 1, sel.startLineNumber, model.getLineMaxColumn(sel.startLineNumber));
853
+ const instruction = await askText(t("ide.inline"), t("ide.inline.hint"), "");
854
+ if (!instruction) return;
855
+ if (!session && !(await newAgent())) return;
856
+ const selection = model.getValueInRange(range);
857
+ const ctxStart = Math.max(1, range.startLineNumber - 40), ctxEnd = Math.min(model.getLineCount(), range.endLineNumber + 40);
858
+ const context = model.getValueInRange(new monaco.Range(ctxStart, 1, ctxEnd, model.getLineMaxColumn(ctxEnd)));
859
+ output(t("ide.inline.working"));
860
+ try {
861
+ const r = await api("/api/projects/edit-selection", { id: session.id, path: active.path, selection, instruction, context });
862
+ const before = model.getValue();
863
+ const after = before.slice(0, model.getOffsetAt(range.getStartPosition())) + r.replacement + before.slice(model.getOffsetAt(range.getEndPosition()));
864
+ const tab = active;
865
+ showReview({ title: t("ide.inline.review", { path: tab.path }), before, after, path: tab.path, pieces: true, acceptLabel: t("ide.inline.accept"), rejectLabel: t("pj.refuse"),
866
+ accept: (text) => { tab.model.pushEditOperations([], [{ range: tab.model.getFullModelRange(), text }], () => null); activate(tab); }, reject: () => activate(tab) });
867
+ } catch (err) { output(explain(err)); }
868
+ }
869
+
870
+ // ---- keys ------------------------------------------------------------------------------------------
871
+ const KEYS = [["P", "ide.key.quick"], ["⇧F", "ide.key.search"], ["B", "ide.key.side"], ["J", "ide.key.terminal"], ["L", "ide.key.agent"], ["K", "ide.key.inline"], ["S", "ide.key.save"]];
872
+ function drawKeys() {
873
+ const list = clear($("keys"));
874
+ const mod = isMac ? "⌘" : "Ctrl+";
875
+ for (const [k, label] of KEYS) list.append(el("li", { text: `${mod}${k} ${t(label)}` }));
876
+ list.append(el("li", { text: `⇧Tab ${t("ide.key.mode")}` }));
877
+ }
878
+ document.addEventListener("keydown", (e) => {
879
+ const mod = isMac ? e.metaKey : e.ctrlKey;
880
+ const inEditor = e.target && e.target.closest && e.target.closest(".monaco-editor");
881
+ if (e.key === "Tab" && e.shiftKey && !inEditor && !mentionItems.length) {
882
+ e.preventDefault();
883
+ setMode(MODES[(MODES.indexOf($("mode").value) + 1) % MODES.length]);
884
+ return;
885
+ }
886
+ if (!mod) return;
887
+ const k = e.key.toLowerCase();
888
+ if (k === "p" && !e.shiftKey) { e.preventDefault(); quickOpen(); }
889
+ else if (k === "f" && e.shiftKey) { e.preventDefault(); showSide("search"); }
890
+ else if (k === "b" && !e.shiftKey) { e.preventDefault(); sizes.sideOpen = !sizes.sideOpen; applySizes(); }
891
+ else if (k === "j") { e.preventDefault(); if (sizes.bottomOpen && document.querySelector('[data-bottom="terminal"]').classList.contains("on") && $("terminal-host").querySelector(".bp-term")) { sizes.bottomOpen = false; applySizes(); if (terminal) terminal.close(); } else showTerminal(); }
892
+ else if (k === "l") { e.preventDefault(); newAgent(); }
893
+ else if (k === "s" && !inEditor) { e.preventDefault(); saveActive(); }
894
+ }, true);
895
+ addEventListener("beforeunload", (e) => { if (tabs.some(isDirty)) { e.preventDefault(); e.returnValue = ""; } });
896
+
897
+ // ---- start ------------------------------------------------------------------------------------------
898
+ (async () => {
899
+ await loadWords();
900
+ $("ide").hidden = false;
901
+ $("back").href = `/#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`;
902
+ applySizes();
903
+ drawKeys();
904
+ renderModeHint();
905
+ try { monaco = await loadMonaco(); setupEditor(); } catch (err) { output(t("ide.err.editor_missing")); }
906
+ renderTabs();
907
+ await Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]);
908
+ if ($("folder").value) await setFolder($("folder").value);
909
+ })();
910
+ })();