smolcoder-plus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/agent.js +748 -0
- package/dist/attachments.js +158 -0
- package/dist/config.js +87 -0
- package/dist/context.js +498 -0
- package/dist/detect.js +474 -0
- package/dist/events.js +24 -0
- package/dist/history.js +9 -0
- package/dist/hosts.js +107 -0
- package/dist/index.js +391 -0
- package/dist/logo.js +48 -0
- package/dist/netscan.js +159 -0
- package/dist/network.js +193 -0
- package/dist/plan.js +102 -0
- package/dist/prompt.js +84 -0
- package/dist/providers/lmstudio.js +347 -0
- package/dist/providers/ollama.js +269 -0
- package/dist/providers/scheduler.js +57 -0
- package/dist/providers/transport.js +86 -0
- package/dist/providers/types.js +62 -0
- package/dist/sandbox.js +207 -0
- package/dist/session.js +639 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +431 -0
- package/dist/tools/index.js +260 -0
- package/dist/tools/search-worker.js +34 -0
- package/dist/tools/shell.js +186 -0
- package/dist/tools/tasks.js +147 -0
- package/dist/tools/web-search.js +155 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +723 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +91 -0
- package/dist/verification.js +71 -0
- package/dist/web/channel.js +260 -0
- package/dist/web/client.js +1010 -0
- package/dist/web/hub.js +952 -0
- package/dist/web/page.js +87 -0
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +333 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The browser-side script for the web page. Plain JS in a raw template string
|
|
3
|
+
// (no build step, no dependencies). Rules: no template literals in here, and
|
|
4
|
+
// a literal backtick is written as \` — String.raw hands it through as-is.
|
|
5
|
+
//
|
|
6
|
+
// Layout: a workspace sidebar on the left, the active session's transcript in
|
|
7
|
+
// the middle, and an optional right panel with browser and terminal tabs.
|
|
8
|
+
// Every session keeps its own view (transcript DOM, status, draft input,
|
|
9
|
+
// panel tabs) so switching is instant and background sessions keep streaming.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.CLIENT_JS = void 0;
|
|
12
|
+
exports.CLIENT_JS = String.raw `
|
|
13
|
+
"use strict";
|
|
14
|
+
const k = new URLSearchParams(location.search).get("k") || "";
|
|
15
|
+
const $ = (id) => document.getElementById(id);
|
|
16
|
+
const ls = {
|
|
17
|
+
get(key) { try { return localStorage.getItem(key); } catch (e) { return null; } },
|
|
18
|
+
set(key, v) { try { localStorage.setItem(key, v); } catch (e) {} },
|
|
19
|
+
};
|
|
20
|
+
const logwrap = $("logwrap"), logsEl = $("logs"), busyEl = $("busy"), actionBtn = $("actionbtn");
|
|
21
|
+
const jumpBottom = $("jumpbottom");
|
|
22
|
+
const input = $("input"), menu = $("menu"), sideEl = $("side"), panelEl = $("panel"), tabsEl = $("paneltabs");
|
|
23
|
+
|
|
24
|
+
let hub = { workspaces: [], home: "", version: "" };
|
|
25
|
+
const sessInfo = new Map(); // sid -> sidebar entry from the last hub snapshot
|
|
26
|
+
const views = new Map(); // sid -> per-session view state
|
|
27
|
+
let active = null;
|
|
28
|
+
let pendingSelect = null;
|
|
29
|
+
let busyTimer = null;
|
|
30
|
+
let uidCounter = 0;
|
|
31
|
+
const uid = () => "u" + (++uidCounter) + "_" + Date.now().toString(36);
|
|
32
|
+
|
|
33
|
+
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
|
|
34
|
+
function post(path, body) {
|
|
35
|
+
return fetch(path + "?k=" + k, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) })
|
|
36
|
+
.then((r) => r.json()).catch(() => ({}));
|
|
37
|
+
}
|
|
38
|
+
function rel(ts) {
|
|
39
|
+
const d = Date.now() - (ts || 0);
|
|
40
|
+
if (!ts || d < 60e3) return "now";
|
|
41
|
+
if (d < 3600e3) return Math.floor(d / 60e3) + "m";
|
|
42
|
+
if (d < 86400e3) return Math.floor(d / 3600e3) + "h";
|
|
43
|
+
if (d < 7 * 86400e3) return Math.floor(d / 86400e3) + "d";
|
|
44
|
+
return new Date(ts).toLocaleDateString();
|
|
45
|
+
}
|
|
46
|
+
function shortPath(p) {
|
|
47
|
+
if (!p) return "";
|
|
48
|
+
const home = hub.home || "";
|
|
49
|
+
if (home && p.slice(0, home.length).toLowerCase() === home.toLowerCase()) p = "~" + p.slice(home.length);
|
|
50
|
+
return p.replace(/\\/g, "/");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- markdown ------------------------------------------------------------
|
|
54
|
+
// Model output is untrusted: escape everything first, then build tags
|
|
55
|
+
// ourselves. Nothing from the model is ever inserted as raw HTML.
|
|
56
|
+
function esc(s) {
|
|
57
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
58
|
+
}
|
|
59
|
+
const SENT = String.fromCharCode(1); // never appears in escaped text
|
|
60
|
+
function inlineMd(s) {
|
|
61
|
+
const codes = [];
|
|
62
|
+
s = s.replace(/\`([^\`]+)\`/g, (m, c) => { codes.push(c); return SENT + (codes.length - 1) + SENT; });
|
|
63
|
+
s = s.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
64
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
65
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
66
|
+
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
67
|
+
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)"]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
68
|
+
s = s.replace(new RegExp(SENT + "(\\d+)" + SENT, "g"), (m, i) => "<code>" + codes[i] + "</code>");
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
function renderMarkdown(src) {
|
|
72
|
+
const lines = esc(src).split("\n");
|
|
73
|
+
let out = "", i = 0, listType = null;
|
|
74
|
+
const closeList = () => { if (listType) { out += "</" + listType + ">"; listType = null; } };
|
|
75
|
+
const cells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
76
|
+
while (i < lines.length) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
const fence = /^\s*\`\`\`(\w*)\s*$/.exec(line);
|
|
79
|
+
if (fence) {
|
|
80
|
+
closeList();
|
|
81
|
+
const body = []; i++;
|
|
82
|
+
while (i < lines.length && !/^\s*\`\`\`/.test(lines[i])) { body.push(lines[i]); i++; }
|
|
83
|
+
i++;
|
|
84
|
+
out += "<pre><code>" + body.join("\n") + "</code></pre>";
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1])) {
|
|
88
|
+
closeList();
|
|
89
|
+
const head = cells(line); i += 2;
|
|
90
|
+
const rows = [];
|
|
91
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
|
|
92
|
+
out += "<table><thead><tr>" + head.map((h) => "<th>" + inlineMd(h) + "</th>").join("") + "</tr></thead><tbody>";
|
|
93
|
+
for (const r of rows) out += "<tr>" + r.map((c) => "<td>" + inlineMd(c) + "</td>").join("") + "</tr>";
|
|
94
|
+
out += "</tbody></table>";
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
98
|
+
if (h) { closeList(); out += "<h" + h[1].length + ">" + inlineMd(h[2]) + "</h" + h[1].length + ">"; i++; continue; }
|
|
99
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(line)) { closeList(); out += "<hr>"; i++; continue; }
|
|
100
|
+
// NB: lines are already escaped, so the blockquote marker is ">".
|
|
101
|
+
if (/^\s*>\s?/.test(line)) {
|
|
102
|
+
closeList();
|
|
103
|
+
const body = [];
|
|
104
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, "")); i++; }
|
|
105
|
+
out += "<blockquote>" + inlineMd(body.join(" ")) + "</blockquote>";
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
109
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
110
|
+
if (ul || ol) {
|
|
111
|
+
const want = ul ? "ul" : "ol";
|
|
112
|
+
if (listType !== want) { closeList(); out += "<" + want + ">"; listType = want; }
|
|
113
|
+
out += "<li>" + inlineMd((ul || ol)[1]) + "</li>";
|
|
114
|
+
i++; continue;
|
|
115
|
+
}
|
|
116
|
+
if (!line.trim()) { closeList(); i++; continue; }
|
|
117
|
+
closeList();
|
|
118
|
+
const para = [line]; i++;
|
|
119
|
+
while (i < lines.length && lines[i].trim() &&
|
|
120
|
+
!/^(\s*#{1,6}\s|\s*\`\`\`|\s*>\s?|\s*[-*+]\s|\s*\d+[.)]\s|\s*\|)/.test(lines[i])) { para.push(lines[i]); i++; }
|
|
121
|
+
out += "<p>" + inlineMd(para.join(" ")) + "</p>";
|
|
122
|
+
}
|
|
123
|
+
closeList();
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- ANSI (terminal output) -----------------------------------------------
|
|
128
|
+
function ansiToFrag(text) {
|
|
129
|
+
const frag = document.createDocumentFragment();
|
|
130
|
+
const re = /\x1b\[([\d;]*)m/g;
|
|
131
|
+
let last = 0, m, cls = [];
|
|
132
|
+
const push = (s) => {
|
|
133
|
+
if (!s) return;
|
|
134
|
+
if (cls.length) frag.appendChild(el("span", cls.join(" "), s)); else frag.appendChild(document.createTextNode(s));
|
|
135
|
+
};
|
|
136
|
+
while ((m = re.exec(text))) {
|
|
137
|
+
push(text.slice(last, m.index));
|
|
138
|
+
last = re.lastIndex;
|
|
139
|
+
const codes = (m[1] || "0").split(";").map(Number);
|
|
140
|
+
for (let i = 0; i < codes.length; i++) {
|
|
141
|
+
const c = codes[i];
|
|
142
|
+
if (c === 0) cls = [];
|
|
143
|
+
else if (c === 1) cls.push("ab");
|
|
144
|
+
else if (c === 2) cls.push("ad");
|
|
145
|
+
else if (c === 22) cls = cls.filter((x) => x !== "ab" && x !== "ad");
|
|
146
|
+
else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) { cls = cls.filter((x) => !/^a[39]\d$/.test(x)); cls.push("a" + c); }
|
|
147
|
+
else if (c === 39) cls = cls.filter((x) => !/^a[39]\d$/.test(x));
|
|
148
|
+
else if (c === 38 || c === 48) break; // 256/truecolor: not rendered
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
push(text.slice(last));
|
|
152
|
+
return frag;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- per-session views ----------------------------------------------------
|
|
156
|
+
function getView(sid) {
|
|
157
|
+
let v = views.get(sid);
|
|
158
|
+
if (!v) {
|
|
159
|
+
v = {
|
|
160
|
+
sid, logEl: el("div", "log"), state: { commands: [] }, curText: null, curThought: null, thoughtBuf: "", thoughtStart: 0,
|
|
161
|
+
busyLabel: null, busyStart: 0, unread: false, draft: "", scrollTop: null, followBottom: true, asks: new Map(), curTool: null, planEl: null,
|
|
162
|
+
terms: new Map(), tabs: [], activeTab: null, panelOpen: false, panelEl: el("div", "panelview"),
|
|
163
|
+
};
|
|
164
|
+
v.logEl.hidden = true; logsEl.appendChild(v.logEl);
|
|
165
|
+
v.panelEl.hidden = true; $("panelviews").appendChild(v.panelEl);
|
|
166
|
+
loadPanelState(v);
|
|
167
|
+
views.set(sid, v);
|
|
168
|
+
}
|
|
169
|
+
return v;
|
|
170
|
+
}
|
|
171
|
+
function dropView(sid) {
|
|
172
|
+
const v = views.get(sid);
|
|
173
|
+
if (!v) return;
|
|
174
|
+
v.logEl.remove(); v.panelEl.remove();
|
|
175
|
+
views.delete(sid);
|
|
176
|
+
if (active === v) show(null);
|
|
177
|
+
}
|
|
178
|
+
// Only follow while at the bottom. Keep this per session, so returning to a
|
|
179
|
+
// transcript doesn't lose the place the user was reading.
|
|
180
|
+
function atBottom() { return logwrap.scrollHeight - logwrap.scrollTop - logwrap.clientHeight <= 2; }
|
|
181
|
+
function renderJumpBottom() { jumpBottom.hidden = !active || atBottom(); }
|
|
182
|
+
function stick() {
|
|
183
|
+
if (active) {
|
|
184
|
+
// A token can arrive before the browser delivers a pending scroll event.
|
|
185
|
+
if (active.scrollTop !== null && logwrap.scrollTop < active.scrollTop && !atBottom()) active.followBottom = false;
|
|
186
|
+
if (active.followBottom) logwrap.scrollTop = logwrap.scrollHeight;
|
|
187
|
+
active.scrollTop = logwrap.scrollTop;
|
|
188
|
+
}
|
|
189
|
+
renderJumpBottom();
|
|
190
|
+
}
|
|
191
|
+
function scrollToBottom() {
|
|
192
|
+
if (!active) return;
|
|
193
|
+
active.followBottom = true;
|
|
194
|
+
logwrap.scrollTop = logwrap.scrollHeight;
|
|
195
|
+
active.scrollTop = logwrap.scrollTop;
|
|
196
|
+
renderJumpBottom();
|
|
197
|
+
}
|
|
198
|
+
logwrap.addEventListener("scroll", () => {
|
|
199
|
+
if (active) { active.followBottom = atBottom(); active.scrollTop = logwrap.scrollTop; }
|
|
200
|
+
renderJumpBottom();
|
|
201
|
+
}, { passive: true });
|
|
202
|
+
logwrap.addEventListener("wheel", (e) => {
|
|
203
|
+
if (active && e.deltaY < 0 && logwrap.scrollTop > 0) active.followBottom = false;
|
|
204
|
+
}, { passive: true });
|
|
205
|
+
jumpBottom.onclick = scrollToBottom;
|
|
206
|
+
// Images, disclosure panels, and viewport/composer resizing can change the
|
|
207
|
+
// transcript height without a new message event.
|
|
208
|
+
const logResize = new ResizeObserver(stick);
|
|
209
|
+
logResize.observe(logwrap); logResize.observe(logsEl); logResize.observe($("busywrap"));
|
|
210
|
+
function add(v, e) { v.logEl.appendChild(e); if (v === active) stick(); return e; }
|
|
211
|
+
function fmtSize(n) { return n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB" : (n / 1048576).toFixed(1) + " MB"; }
|
|
212
|
+
// A sent message: its text, then thumbnails for images and links for files.
|
|
213
|
+
function userBubble(m) {
|
|
214
|
+
const d = el("div", "user", m.s || "");
|
|
215
|
+
if (m.files && m.files.length) {
|
|
216
|
+
const row = el("div", "files");
|
|
217
|
+
for (const f of m.files) {
|
|
218
|
+
const href = f.url + "&k=" + k;
|
|
219
|
+
if (f.kind === "image") {
|
|
220
|
+
const a = el("a"); a.href = href; a.target = "_blank"; a.rel = "noopener";
|
|
221
|
+
const img = el("img", "thumb"); img.src = href; img.alt = f.name; img.title = f.name; img.loading = "lazy";
|
|
222
|
+
a.appendChild(img); row.appendChild(a);
|
|
223
|
+
} else {
|
|
224
|
+
const a = el("a", "filechip", f.name + " · " + fmtSize(f.size)); a.href = href; a.target = "_blank"; a.rel = "noopener";
|
|
225
|
+
row.appendChild(a);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
d.appendChild(row);
|
|
229
|
+
}
|
|
230
|
+
return d;
|
|
231
|
+
}
|
|
232
|
+
// Streaming: accumulate raw markdown on the element, re-render on a short
|
|
233
|
+
// timer. NOT requestAnimationFrame: rAF never fires in background tabs, so a
|
|
234
|
+
// response streamed while the tab is hidden would never render.
|
|
235
|
+
function scheduleMd(v, target) {
|
|
236
|
+
if (target._pending) return;
|
|
237
|
+
target._pending = true;
|
|
238
|
+
setTimeout(() => { target._pending = false; target.innerHTML = renderMarkdown(target._raw || ""); if (v === active) stick(); }, 60);
|
|
239
|
+
}
|
|
240
|
+
function endThought(v) {
|
|
241
|
+
if (v.curThought) {
|
|
242
|
+
v.curThought._preview.textContent = "✦ thought for " + ((Date.now() - v.thoughtStart) / 1000).toFixed(1) + "s";
|
|
243
|
+
v.curThought = null; v.thoughtBuf = "";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function startThought(v) {
|
|
247
|
+
const thought = el("details", "thought"), summary = el("summary");
|
|
248
|
+
thought._preview = el("span", "thought-preview");
|
|
249
|
+
summary.appendChild(thought._preview);
|
|
250
|
+
summary.appendChild(el("span", "thought-expand", "Expand"));
|
|
251
|
+
summary.appendChild(el("span", "thought-collapse", "Collapse"));
|
|
252
|
+
const body = el("div", "thought-body");
|
|
253
|
+
thought._body = document.createTextNode(""); body.appendChild(thought._body);
|
|
254
|
+
thought.appendChild(summary); thought.appendChild(body);
|
|
255
|
+
summary.addEventListener("click", () => {
|
|
256
|
+
// Opening a long thought should keep its beginning in view, including
|
|
257
|
+
// when it is still streaming. Native summary activation handles keys too.
|
|
258
|
+
if (v === active && !thought.open) v.followBottom = false;
|
|
259
|
+
});
|
|
260
|
+
thought.addEventListener("toggle", () => {
|
|
261
|
+
// Collapsing can put us back at the bottom without changing scrollTop,
|
|
262
|
+
// so there may be no scroll event to resume following.
|
|
263
|
+
if (v === active) { v.followBottom = atBottom(); stick(); }
|
|
264
|
+
});
|
|
265
|
+
v.thoughtStart = Date.now(); v.thoughtBuf = ""; v.curText = null;
|
|
266
|
+
return add(v, thought);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function setBusy(v, label) {
|
|
270
|
+
v.busyLabel = label || null;
|
|
271
|
+
if (label) v.busyStart = Date.now();
|
|
272
|
+
if (v === active) renderBusy();
|
|
273
|
+
}
|
|
274
|
+
function renderBusy() {
|
|
275
|
+
const label = active ? active.busyLabel : null;
|
|
276
|
+
actionBtn.textContent = label ? "■ stop" : "send";
|
|
277
|
+
actionBtn.className = label ? "stop" : "";
|
|
278
|
+
actionBtn.title = label ? "interrupt the agent (esc)" : "send (enter)";
|
|
279
|
+
const tick = () => {
|
|
280
|
+
const s = active && active.busyLabel ? Math.floor((Date.now() - active.busyStart) / 1000) : 0;
|
|
281
|
+
$("busysecs").textContent = s > 2 ? s + "s" : "";
|
|
282
|
+
};
|
|
283
|
+
if (label) {
|
|
284
|
+
busyEl.classList.add("on");
|
|
285
|
+
$("busylabel").textContent = label + "…";
|
|
286
|
+
if (!busyTimer) busyTimer = setInterval(tick, 500);
|
|
287
|
+
tick();
|
|
288
|
+
} else {
|
|
289
|
+
busyEl.classList.remove("on");
|
|
290
|
+
if (busyTimer) { clearInterval(busyTimer); busyTimer = null; }
|
|
291
|
+
$("busysecs").textContent = "";
|
|
292
|
+
}
|
|
293
|
+
stick();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderState(v) {
|
|
297
|
+
for (const t of v.tabs) if (t.kind === "browser") fillUrls(t, v.state.urls || []);
|
|
298
|
+
if (v !== active) return;
|
|
299
|
+
const s = v.state;
|
|
300
|
+
const st = $("status");
|
|
301
|
+
st.innerHTML = "";
|
|
302
|
+
if (!s.mode) { st.textContent = "starting…"; renderCrumb(); return; }
|
|
303
|
+
const command = (name) => post("/msg", { sid: v.sid, text: "/" + name });
|
|
304
|
+
const mode = el("button", "statusbtn mode " + s.mode, s.mode === "ro" ? "Read-only" : s.mode === "bypass" ? "Bypass" : "Edit");
|
|
305
|
+
mode.title = "Permission mode"; mode.onclick = () => command("mode");
|
|
306
|
+
st.appendChild(mode);
|
|
307
|
+
const model = el("button", "statusbtn modelpick", s.model + (s.host ? " @ " + s.host : "") + " ▾"); model.title = s.backend + (s.host ? " on " + s.host : "") + " · Switch model or find models on another machine"; model.onclick = () => command("models"); st.appendChild(model);
|
|
308
|
+
const effort = el("button", "statusbtn eff", s.effort || "Auto"); effort.title = "Reasoning effort"; effort.onclick = () => command("effort"); st.appendChild(effort);
|
|
309
|
+
st.appendChild(el("span", "grow"));
|
|
310
|
+
const ctx = el("button", "statusbtn context-chip" + (s.ctxPct >= 75 ? " pressure" : ""));
|
|
311
|
+
const meter = document.createElement("meter"); meter.min = 0; meter.max = 100; meter.value = s.ctxPct || 0; meter.setAttribute("aria-label", "Context used");
|
|
312
|
+
ctx.appendChild(meter); ctx.appendChild(document.createTextNode((s.ctxPct || 0) + "%"));
|
|
313
|
+
const b = s.context; ctx.title = b ? b.prompt.toLocaleString() + " / " + b.window.toLocaleString() + " tokens · " + b.reserve.toLocaleString() + " reserved for reply · " + b.source : "Context usage";
|
|
314
|
+
ctx.onclick = () => command("context"); st.appendChild(ctx);
|
|
315
|
+
if (s.plan) {
|
|
316
|
+
const done = s.plan.steps.filter((x) => x.done).length;
|
|
317
|
+
st.appendChild(el("span", "plan-chip" + (s.plan.current < 0 ? " done" : ""), "plan " + done + "/" + s.plan.steps.length));
|
|
318
|
+
}
|
|
319
|
+
if (s.tasks) st.appendChild(el("span", "task-chip", s.tasks + " running"));
|
|
320
|
+
if (s.outcome === "error" || s.outcome === "cancelled") {
|
|
321
|
+
const paused = el("span", s.outcome === "error" ? "line-warn" : "task-chip", s.outcome === "error" ? "Paused" : "Stopped");
|
|
322
|
+
paused.title = s.lastError || "Turn cancelled; progress is kept"; st.appendChild(paused);
|
|
323
|
+
}
|
|
324
|
+
$("ws").textContent = shortPath(s.workspace || "");
|
|
325
|
+
renderCrumb();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderPlan(v, p) {
|
|
329
|
+
const box = el("div", "plan");
|
|
330
|
+
const done = p.steps.filter((x) => x.done).length;
|
|
331
|
+
const hdr = el("div", "hdr", "Plan ");
|
|
332
|
+
hdr.appendChild(el("small", "", done + "/" + p.steps.length));
|
|
333
|
+
box.appendChild(hdr);
|
|
334
|
+
p.steps.forEach((s, i) => {
|
|
335
|
+
const cls = s.done ? "done" : i === p.current ? "cur" : "todo";
|
|
336
|
+
const mark = s.done ? "✔ " : i === p.current ? "▶ " : "○ ";
|
|
337
|
+
box.appendChild(el("div", cls, mark + s.text));
|
|
338
|
+
});
|
|
339
|
+
if (v.planEl && v.planEl.isConnected) v.planEl.replaceWith(box); else add(v, box);
|
|
340
|
+
v.planEl = box;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- event handling -------------------------------------------------------
|
|
344
|
+
function handle(m) {
|
|
345
|
+
if (m.t === "hub") { onHub(m); return; }
|
|
346
|
+
if (m.t === "closed") { dropView(m.sid); return; }
|
|
347
|
+
if (!m.sid) return;
|
|
348
|
+
const v = getView(m.sid);
|
|
349
|
+
switch (m.t) {
|
|
350
|
+
case "state":
|
|
351
|
+
v.state = Object.assign(v.state, m.s);
|
|
352
|
+
if (m.s && "busy" in m.s) setBusy(v, m.s.busy);
|
|
353
|
+
renderState(v); break;
|
|
354
|
+
case "user": endThought(v); v.curText = null; v.curTool = null; v.planEl = null; add(v, userBubble(m)); break;
|
|
355
|
+
case "response_reset":
|
|
356
|
+
if (v.curText) v.curText.remove();
|
|
357
|
+
if (v.curThought) v.curThought.remove();
|
|
358
|
+
v.curText = null; v.curThought = null; v.thoughtBuf = ""; break;
|
|
359
|
+
case "token":
|
|
360
|
+
endThought(v);
|
|
361
|
+
if (!v.curText) { v.curText = add(v, el("div", "md")); v.curText._raw = ""; }
|
|
362
|
+
v.curText._raw += m.s; scheduleMd(v, v.curText); break;
|
|
363
|
+
case "thinking":
|
|
364
|
+
if (!v.curThought) v.curThought = startThought(v);
|
|
365
|
+
v.curThought._body.appendData(m.s);
|
|
366
|
+
// Only the one-line preview is truncated; the full text stays available.
|
|
367
|
+
v.thoughtBuf = (v.thoughtBuf + m.s).slice(-2000);
|
|
368
|
+
var tt = v.thoughtBuf.replace(/\s+/g, " ").trim();
|
|
369
|
+
v.curThought._preview.textContent = "✦ " + (tt.length > 160 ? "…" + tt.slice(-160) : tt);
|
|
370
|
+
if (v === active) stick(); break;
|
|
371
|
+
case "tool": {
|
|
372
|
+
endThought(v); v.curText = null;
|
|
373
|
+
const d = el("details", "tool");
|
|
374
|
+
const summary = el("summary"); summary.appendChild(el("span", "name", m.name.replace(/_/g, " "))); summary.appendChild(el("span", "tool-args", m.summary || ""));
|
|
375
|
+
d.appendChild(summary); v.curTool = d; add(v, d); break;
|
|
376
|
+
}
|
|
377
|
+
case "result": {
|
|
378
|
+
endThought(v); v.curText = null;
|
|
379
|
+
const body = el("pre", "result" + (m.err ? " err" : ""), m.body || m.line);
|
|
380
|
+
if (v.curTool) { v.curTool.classList.add(m.err ? "failed" : "finished"); v.curTool.appendChild(body); if (m.err) v.curTool.open = true; v.curTool = null; }
|
|
381
|
+
else add(v, body);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "plan": endThought(v); v.curText = null; renderPlan(v, m); break;
|
|
385
|
+
case "line": endThought(v); v.curText = null; add(v, el("div", "line-" + m.kind, m.s)); break;
|
|
386
|
+
case "turnend":
|
|
387
|
+
endThought(v); v.curText = null; add(v, el("div", "turnend", "■ " + m.label));
|
|
388
|
+
if (v !== active) { v.unread = true; renderSidebar(); }
|
|
389
|
+
break;
|
|
390
|
+
case "busy": setBusy(v, m.label); break;
|
|
391
|
+
case "confirm": {
|
|
392
|
+
endThought(v); v.curText = null;
|
|
393
|
+
const box = el("div", "ask");
|
|
394
|
+
box.appendChild(el("div", "", "run?")); box.appendChild(el("div", "cmd", m.command));
|
|
395
|
+
if (m.reason) box.appendChild(el("div", "hint", m.reason));
|
|
396
|
+
["yes", "no", "always"].forEach((a) => {
|
|
397
|
+
const b = el("button", "", a === "always" ? "always allow this program" : a);
|
|
398
|
+
b.onclick = () => { post("/confirm", { sid: v.sid, id: m.id, answer: a }); box.remove(); };
|
|
399
|
+
box.appendChild(b);
|
|
400
|
+
});
|
|
401
|
+
v.asks.set(m.id, box);
|
|
402
|
+
add(v, box); break;
|
|
403
|
+
}
|
|
404
|
+
case "answered": {
|
|
405
|
+
// The server settled this prompt (answered here, elsewhere, or cancelled).
|
|
406
|
+
const box = v.asks.get(m.id);
|
|
407
|
+
if (box) { box.remove(); v.asks.delete(m.id); }
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "select": {
|
|
411
|
+
endThought(v); v.curText = null;
|
|
412
|
+
const box = el("div", "ask");
|
|
413
|
+
box.appendChild(el("div", "cmd", m.title));
|
|
414
|
+
m.options.forEach((o, i) => {
|
|
415
|
+
const b = el("button", "opt" + (o.current ? " current" : ""), (o.current ? "● " : "") + o.label);
|
|
416
|
+
if (o.hint) b.appendChild(el("span", "hint", o.hint));
|
|
417
|
+
b.onclick = () => { post("/select", { sid: v.sid, id: m.id, index: i }); box.remove(); };
|
|
418
|
+
box.appendChild(el("div")).appendChild(b);
|
|
419
|
+
});
|
|
420
|
+
const cancel = el("button", "", "cancel");
|
|
421
|
+
cancel.onclick = () => { post("/select", { sid: v.sid, id: m.id, index: null }); box.remove(); };
|
|
422
|
+
box.appendChild(cancel);
|
|
423
|
+
v.asks.set(m.id, box);
|
|
424
|
+
add(v, box); break;
|
|
425
|
+
}
|
|
426
|
+
case "prompt": {
|
|
427
|
+
endThought(v); v.curText = null;
|
|
428
|
+
const box = el("div", "ask");
|
|
429
|
+
box.appendChild(el("div", "cmd", m.title));
|
|
430
|
+
const field = el("input", "askinput"); field.type = "text"; field.placeholder = m.placeholder || ""; field.spellcheck = false; field.autocomplete = "off";
|
|
431
|
+
const send = (value) => { post("/prompt", { sid: v.sid, id: m.id, value: value }); box.remove(); };
|
|
432
|
+
field.onkeydown = (e) => {
|
|
433
|
+
if (e.key === "Enter") { e.preventDefault(); send(field.value.trim() || null); }
|
|
434
|
+
// Escape closes this box only; the page-level handler would cancel the whole turn.
|
|
435
|
+
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); send(null); }
|
|
436
|
+
};
|
|
437
|
+
box.appendChild(el("div")).appendChild(field);
|
|
438
|
+
const ok = el("button", "", "ok"); ok.onclick = () => send(field.value.trim() || null); box.appendChild(ok);
|
|
439
|
+
const cancel = el("button", "", "cancel"); cancel.onclick = () => send(null); box.appendChild(cancel);
|
|
440
|
+
v.asks.set(m.id, box);
|
|
441
|
+
add(v, box);
|
|
442
|
+
if (v === active) field.focus();
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case "termopen": ensureTermTab(v, m.tid, m.cwd); break;
|
|
446
|
+
case "term": termWrite(v, m.tid, m.s); break;
|
|
447
|
+
case "termdone": termDone(v, m.tid, m.code, m.cwd); break;
|
|
448
|
+
case "termclosed": removeTermTab(v, m.tid); break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---- hub snapshot + sidebar ------------------------------------------------
|
|
453
|
+
function onHub(m) {
|
|
454
|
+
hub = m;
|
|
455
|
+
sessInfo.clear();
|
|
456
|
+
for (const w of m.workspaces) for (const s of w.sessions) sessInfo.set(s.id, Object.assign({ workspace: w.path, wsname: w.name }, s));
|
|
457
|
+
for (const v of views.values()) reconcileTerms(v);
|
|
458
|
+
$("ver").textContent = "v" + m.version;
|
|
459
|
+
if (pendingSelect && sessInfo.has(pendingSelect)) { const id = pendingSelect; pendingSelect = null; show(id); }
|
|
460
|
+
else if (!active) {
|
|
461
|
+
const h = location.hash.slice(1);
|
|
462
|
+
if (h && sessInfo.has(h)) select(h);
|
|
463
|
+
else {
|
|
464
|
+
const live = [...sessInfo.values()].filter((s) => s.live).sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
465
|
+
show(live ? live.id : null);
|
|
466
|
+
}
|
|
467
|
+
} else if (!sessInfo.has(active.sid) && !pendingSelect) show(null);
|
|
468
|
+
renderSidebar(); renderCrumb(); renderTitle(); renderWelcome();
|
|
469
|
+
}
|
|
470
|
+
function select(id) {
|
|
471
|
+
const s = sessInfo.get(id);
|
|
472
|
+
if (s && (!s.live || s.status === "error")) post("/sessions/resume", { id });
|
|
473
|
+
show(id);
|
|
474
|
+
}
|
|
475
|
+
function show(sid) {
|
|
476
|
+
if (active) {
|
|
477
|
+
active.draft = input.value; active.scrollTop = logwrap.scrollTop;
|
|
478
|
+
active.logEl.hidden = true;
|
|
479
|
+
}
|
|
480
|
+
active = sid ? getView(sid) : null;
|
|
481
|
+
$("welcome").hidden = !!active;
|
|
482
|
+
$("bottom").hidden = !active;
|
|
483
|
+
busyEl.hidden = !active;
|
|
484
|
+
if (active) {
|
|
485
|
+
active.logEl.hidden = false; active.unread = false;
|
|
486
|
+
input.value = active.draft || ""; autoGrow(); menuIdx = 0; renderMenu();
|
|
487
|
+
renderState(active);
|
|
488
|
+
logwrap.scrollTop = active.followBottom ? logwrap.scrollHeight : active.scrollTop || 0;
|
|
489
|
+
active.scrollTop = logwrap.scrollTop;
|
|
490
|
+
renderBusy();
|
|
491
|
+
if (location.hash !== "#" + sid) history.replaceState(null, "", "#" + sid);
|
|
492
|
+
// On a narrow window the sidebar floats over the chat: tuck it away once
|
|
493
|
+
// a session is picked.
|
|
494
|
+
if (narrow()) setSide(true);
|
|
495
|
+
input.focus({ preventScroll: true });
|
|
496
|
+
} else {
|
|
497
|
+
if (location.hash) history.replaceState(null, "", location.pathname + location.search);
|
|
498
|
+
$("status").textContent = "";
|
|
499
|
+
}
|
|
500
|
+
renderPanel(); renderSidebar(); renderCrumb(); renderTitle(); renderWelcome(); renderAttachments();
|
|
501
|
+
stick();
|
|
502
|
+
}
|
|
503
|
+
function newSession(path) {
|
|
504
|
+
post("/sessions/new", { workspace: path }).then((r) => { if (r.id) { pendingSelect = r.id; show(r.id); } else if (r.error) alert(r.error); });
|
|
505
|
+
}
|
|
506
|
+
function renderSidebar() {
|
|
507
|
+
const list = $("wslist");
|
|
508
|
+
list.innerHTML = "";
|
|
509
|
+
if (!hub.workspaces.length) return;
|
|
510
|
+
for (const w of hub.workspaces) {
|
|
511
|
+
const box = el("div", "ws");
|
|
512
|
+
const hdr = el("div", "wshdr"); hdr.title = w.path;
|
|
513
|
+
hdr.appendChild(el("span", "wsname", w.name)); hdr.appendChild(el("span", "grow"));
|
|
514
|
+
const plus = el("button", "iconbtn", "+"); plus.title = "new session in " + w.name;
|
|
515
|
+
plus.onclick = (e) => { e.stopPropagation(); newSession(w.path); };
|
|
516
|
+
const rm = el("button", "iconbtn", "×"); rm.title = "remove " + w.name + " from the list";
|
|
517
|
+
rm.onclick = (e) => { e.stopPropagation(); removeWorkspace(w); };
|
|
518
|
+
hdr.appendChild(plus); hdr.appendChild(rm);
|
|
519
|
+
if (!w.sessions.length) { hdr.style.cursor = "pointer"; hdr.onclick = () => newSession(w.path); }
|
|
520
|
+
box.appendChild(hdr);
|
|
521
|
+
const sl = el("div", "sessions");
|
|
522
|
+
for (const s of w.sessions) {
|
|
523
|
+
const v = views.get(s.id);
|
|
524
|
+
const row = el("div", "sess " + (s.live ? s.status : "stored") + (active && active.sid === s.id ? " active" : "") + (v && v.unread ? " unread" : ""));
|
|
525
|
+
row.appendChild(el("span", "dot"));
|
|
526
|
+
row.appendChild(el("span", "stitle" + (s.title ? "" : " untitled"), s.title || "new session"));
|
|
527
|
+
row.appendChild(el("span", "stime", rel(s.updatedAt)));
|
|
528
|
+
const x = el("button", "iconbtn", "×");
|
|
529
|
+
x.title = s.live ? "close session (kept in the list)" : "delete session";
|
|
530
|
+
x.onclick = (e) => {
|
|
531
|
+
e.stopPropagation();
|
|
532
|
+
if (s.live) post("/sessions/close", { id: s.id });
|
|
533
|
+
else if (confirm("Delete this session permanently?")) post("/sessions/delete", { id: s.id });
|
|
534
|
+
};
|
|
535
|
+
row.appendChild(x);
|
|
536
|
+
row.title = (s.title || "new session") + (s.model ? " · " + s.model : "") + (s.live ? " · " + s.status : " · saved — click to resume");
|
|
537
|
+
row.onclick = () => select(s.id);
|
|
538
|
+
row.ondblclick = () => { const t = prompt("Rename session", s.title || ""); if (t !== null && t.trim()) post("/sessions/rename", { id: s.id, title: t }); };
|
|
539
|
+
sl.appendChild(row);
|
|
540
|
+
}
|
|
541
|
+
box.appendChild(sl);
|
|
542
|
+
list.appendChild(box);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function removeWorkspace(w) {
|
|
546
|
+
if (w.sessions.some((s) => s.live)) { alert("Close the open sessions in " + w.name + " first."); return; }
|
|
547
|
+
const n = w.sessions.length;
|
|
548
|
+
if (!confirm("Remove " + w.name + " from the list?" + (n ? " Its " + n + " saved session" + (n > 1 ? "s" : "") + " will be deleted." : ""))) return;
|
|
549
|
+
post("/workspaces/remove", { path: w.path }).then((r) => { if (r.error) alert(r.error); });
|
|
550
|
+
}
|
|
551
|
+
function renderCrumb() {
|
|
552
|
+
const c = $("crumb");
|
|
553
|
+
c.innerHTML = "";
|
|
554
|
+
if (!active) { c.appendChild(el("span", "ws", "smolcoder")); c.title = ""; return; }
|
|
555
|
+
const info = sessInfo.get(active.sid);
|
|
556
|
+
c.appendChild(el("span", "ws", info ? info.wsname : ""));
|
|
557
|
+
c.appendChild(el("span", "sep", "›"));
|
|
558
|
+
c.appendChild(el("span", "title", active.state.title || (info && info.title) || "new session"));
|
|
559
|
+
c.title = info ? info.workspace : "";
|
|
560
|
+
}
|
|
561
|
+
function renderTitle() {
|
|
562
|
+
let busy = false, waiting = false;
|
|
563
|
+
for (const s of sessInfo.values()) { if (s.status === "busy" || s.status === "starting") busy = true; if (s.status === "waiting") waiting = true; }
|
|
564
|
+
const info = active && sessInfo.get(active.sid);
|
|
565
|
+
document.title = (waiting ? "⚠ " : busy ? "● " : "") + (info ? (info.title || info.wsname) + " · " : "") + "smol";
|
|
566
|
+
}
|
|
567
|
+
function renderWelcome() {
|
|
568
|
+
const w = $("welcome");
|
|
569
|
+
w.hidden = !!active;
|
|
570
|
+
if (active) return;
|
|
571
|
+
const r = $("recent");
|
|
572
|
+
r.innerHTML = "";
|
|
573
|
+
const ws = hub.workspaces.slice(0, 8);
|
|
574
|
+
if (!ws.length) return;
|
|
575
|
+
r.appendChild(el("div", "recent-label", "Recent"));
|
|
576
|
+
for (const x of ws) {
|
|
577
|
+
const b = el("button", "wsbtn");
|
|
578
|
+
b.appendChild(el("span", "", x.name)); b.appendChild(el("span", "dim", " " + x.display));
|
|
579
|
+
b.onclick = () => newSession(x.path);
|
|
580
|
+
r.appendChild(b);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
setInterval(() => { renderSidebar(); }, 30000);
|
|
584
|
+
|
|
585
|
+
// ---- folder picker --------------------------------------------------------
|
|
586
|
+
let fsState = { path: "", parent: null };
|
|
587
|
+
function openDialog(start) {
|
|
588
|
+
$("modal").hidden = false;
|
|
589
|
+
browse(start || (active && active.state.workspace) || "");
|
|
590
|
+
setTimeout(() => $("fspath").focus(), 0);
|
|
591
|
+
}
|
|
592
|
+
function closeDialog() { $("modal").hidden = true; }
|
|
593
|
+
function browse(p) {
|
|
594
|
+
fetch("/fs?k=" + k + "&path=" + encodeURIComponent(p || "")).then((r) => r.json()).then(renderFs).catch(() => {});
|
|
595
|
+
}
|
|
596
|
+
function renderFs(d) {
|
|
597
|
+
const list = $("fslist");
|
|
598
|
+
list.innerHTML = "";
|
|
599
|
+
const roots = $("fsroots");
|
|
600
|
+
roots.innerHTML = "";
|
|
601
|
+
const chip = (label, p) => { const c = el("span", "chip", label); c.onclick = () => browse(p); roots.appendChild(c); };
|
|
602
|
+
chip("~ home", d.home);
|
|
603
|
+
for (const r of d.roots || []) chip(r, r);
|
|
604
|
+
if (d.error) { $("fspath").value = d.path || ""; list.appendChild(el("div", "sidehint", d.error)); $("fsopen").disabled = true; return; }
|
|
605
|
+
$("fsopen").disabled = false;
|
|
606
|
+
fsState = d;
|
|
607
|
+
$("fspath").value = d.path;
|
|
608
|
+
$("fsopen").textContent = "Open " + (d.path.split(/[\\/]/).filter(Boolean).pop() || d.path) + (d.project ? " ✦" : "");
|
|
609
|
+
if (d.parent) { const up = el("div", "fsitem up", "↑ .."); up.onclick = () => browse(d.parent); list.appendChild(up); }
|
|
610
|
+
for (const dir of d.dirs) {
|
|
611
|
+
const it = el("div", "fsitem");
|
|
612
|
+
it.appendChild(el("span", "", dir.name + "/"));
|
|
613
|
+
if (dir.project) it.appendChild(el("span", "proj", "✦ project"));
|
|
614
|
+
it.onclick = () => browse(dir.path);
|
|
615
|
+
it.ondblclick = () => openFolder(dir.path);
|
|
616
|
+
list.appendChild(it);
|
|
617
|
+
}
|
|
618
|
+
if (!d.dirs.length) list.appendChild(el("div", "sidehint", "no subfolders"));
|
|
619
|
+
}
|
|
620
|
+
function openFolder(p) {
|
|
621
|
+
post("/workspaces/add", { path: p, start: $("fsstart").checked }).then((r) => {
|
|
622
|
+
if (r.error) { alert(r.error); return; }
|
|
623
|
+
closeDialog();
|
|
624
|
+
if (r.id) { pendingSelect = r.id; show(r.id); }
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
$("openfolder").onclick = () => openDialog();
|
|
628
|
+
$("welcomeopen").onclick = () => openDialog();
|
|
629
|
+
$("fsclose").onclick = closeDialog;
|
|
630
|
+
$("modal").onclick = (e) => { if (e.target === $("modal")) closeDialog(); };
|
|
631
|
+
$("fsgo").onclick = () => browse($("fspath").value);
|
|
632
|
+
$("fspath").onkeydown = (e) => { if (e.key === "Enter") browse($("fspath").value); };
|
|
633
|
+
$("fsopen").onclick = () => openFolder(fsState.path);
|
|
634
|
+
|
|
635
|
+
// ---- right panel: browser + terminal tabs ---------------------------------
|
|
636
|
+
let panelWidth = Math.max(300, Number(ls.get("smol.panel.w")) || 520);
|
|
637
|
+
function panelKey(v) { return "smol.panel." + v.sid; }
|
|
638
|
+
function savePanel(v) {
|
|
639
|
+
ls.set(panelKey(v), JSON.stringify({ open: v.panelOpen, active: v.activeTab, tabs: v.tabs.filter((t) => t.kind === "browser").map((t) => ({ kind: "browser", url: t.url })) }));
|
|
640
|
+
}
|
|
641
|
+
function loadPanelState(v) {
|
|
642
|
+
try {
|
|
643
|
+
const st = JSON.parse(ls.get(panelKey(v)) || "null");
|
|
644
|
+
if (!st) return;
|
|
645
|
+
for (const t of st.tabs || []) {
|
|
646
|
+
if (t.kind !== "browser") continue;
|
|
647
|
+
const tab = { kind: "browser", url: t.url || "", id: uid() };
|
|
648
|
+
buildBrowserTab(v, tab); v.tabs.push(tab);
|
|
649
|
+
}
|
|
650
|
+
v.panelOpen = !!st.open;
|
|
651
|
+
v.activeTab = st.active || (v.tabs[0] && v.tabs[0].id) || null;
|
|
652
|
+
} catch (e) {}
|
|
653
|
+
}
|
|
654
|
+
function curTab(v) { return v.tabs.find((t) => t.id === v.activeTab) || v.tabs[0] || null; }
|
|
655
|
+
function renderPanel() {
|
|
656
|
+
const v = active;
|
|
657
|
+
const open = !!(v && v.panelOpen && v.tabs.length);
|
|
658
|
+
panelEl.hidden = !open;
|
|
659
|
+
const cur = open ? curTab(v) : null;
|
|
660
|
+
$("btnbrowser").classList.toggle("on", !!(cur && cur.kind === "browser"));
|
|
661
|
+
$("btnterm").classList.toggle("on", !!(cur && cur.kind === "term"));
|
|
662
|
+
for (const o of views.values()) o.panelEl.hidden = o !== v || !open;
|
|
663
|
+
if (!open) return;
|
|
664
|
+
// Never let the panel squeeze the chat below ~40% of a small window.
|
|
665
|
+
panelEl.style.width = Math.min(panelWidth, Math.max(300, window.innerWidth * 0.6)) + "px";
|
|
666
|
+
tabsEl.innerHTML = "";
|
|
667
|
+
for (const t of v.tabs) {
|
|
668
|
+
const b = el("div", "ptab" + (t === cur ? " on" : ""));
|
|
669
|
+
b.appendChild(el("span", "ico", t.kind === "browser" ? "◎" : ">_"));
|
|
670
|
+
b.appendChild(el("span", "lbl", t.kind === "browser" ? (t.url ? t.url.replace(/^https?:\/\//, "") : "new tab") : "terminal " + t.tid.replace(/^t/, "")));
|
|
671
|
+
const x = el("span", "x", "×"); x.title = "close tab";
|
|
672
|
+
x.onclick = (e) => { e.stopPropagation(); closeTab(v, t); };
|
|
673
|
+
b.appendChild(x);
|
|
674
|
+
b.onclick = () => { v.activeTab = t.id; savePanel(v); renderPanel(); if (t.kind === "term" && t.inp) t.inp.focus(); };
|
|
675
|
+
b.title = t.kind === "browser" ? (t.url || "new browser tab") : "terminal in " + shortPath(t.cwd);
|
|
676
|
+
tabsEl.appendChild(b);
|
|
677
|
+
}
|
|
678
|
+
tabsEl.appendChild(el("span", "grow"));
|
|
679
|
+
const nb = el("button", "iconbtn", "+◎"); nb.title = "new browser tab"; nb.onclick = () => openBrowserTab(v);
|
|
680
|
+
const nt = el("button", "iconbtn", "+>_"); nt.title = "new terminal"; nt.onclick = () => openTerminalTab(v);
|
|
681
|
+
const cl = el("button", "iconbtn", "»"); cl.title = "hide panel"; cl.onclick = () => { v.panelOpen = false; savePanel(v); renderPanel(); };
|
|
682
|
+
tabsEl.appendChild(nb); tabsEl.appendChild(nt); tabsEl.appendChild(cl);
|
|
683
|
+
for (const t of v.tabs) if (t.el) t.el.hidden = t !== cur;
|
|
684
|
+
}
|
|
685
|
+
function closeTab(v, t) {
|
|
686
|
+
const i = v.tabs.indexOf(t);
|
|
687
|
+
if (t.kind === "term") { post("/term/close", { sid: v.sid, tid: t.tid }); removeTermTab(v, t.tid); return; }
|
|
688
|
+
if (i >= 0) v.tabs.splice(i, 1);
|
|
689
|
+
if (t.el) t.el.remove();
|
|
690
|
+
if (v.activeTab === t.id) v.activeTab = (v.tabs[i] || v.tabs[i - 1] || {}).id || null;
|
|
691
|
+
savePanel(v); renderPanel();
|
|
692
|
+
}
|
|
693
|
+
function togglePanelKind(kind) {
|
|
694
|
+
const v = active;
|
|
695
|
+
if (!v) return;
|
|
696
|
+
const cur = curTab(v);
|
|
697
|
+
if (v.panelOpen && cur && cur.kind === kind) { v.panelOpen = false; savePanel(v); renderPanel(); return; }
|
|
698
|
+
const existing = v.tabs.filter((t) => t.kind === kind).pop();
|
|
699
|
+
if (existing) {
|
|
700
|
+
v.activeTab = existing.id; v.panelOpen = true; savePanel(v); renderPanel();
|
|
701
|
+
if (kind === "term" && existing.inp) existing.inp.focus();
|
|
702
|
+
} else if (kind === "browser") openBrowserTab(v);
|
|
703
|
+
else openTerminalTab(v);
|
|
704
|
+
}
|
|
705
|
+
$("btnbrowser").onclick = () => togglePanelKind("browser");
|
|
706
|
+
$("btnterm").onclick = () => togglePanelKind("term");
|
|
707
|
+
|
|
708
|
+
// browser tabs
|
|
709
|
+
function fillUrls(t, urls) {
|
|
710
|
+
if (!t.dl) return;
|
|
711
|
+
const cur = [...t.dl.options].map((o) => o.value).join("|");
|
|
712
|
+
if (cur === urls.join("|")) return;
|
|
713
|
+
t.dl.innerHTML = "";
|
|
714
|
+
for (const u of urls) { const o = document.createElement("option"); o.value = u; t.dl.appendChild(o); }
|
|
715
|
+
t.urlsEl.innerHTML = "";
|
|
716
|
+
if (urls.length && !t.url) {
|
|
717
|
+
t.urlsEl.appendChild(el("div", "hint", "dev servers the agent started:"));
|
|
718
|
+
for (const u of urls) { const b = el("button", "ghost", u); b.onclick = () => t.nav(u); t.urlsEl.appendChild(b); }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function openBrowserTab(v, url) {
|
|
722
|
+
const t = { kind: "browser", url: "", id: uid() };
|
|
723
|
+
buildBrowserTab(v, t);
|
|
724
|
+
v.tabs.push(t); v.activeTab = t.id; v.panelOpen = true;
|
|
725
|
+
const start = url || (v.state.urls && v.state.urls[0]) || "";
|
|
726
|
+
if (start) t.nav(start); else { savePanel(v); renderPanel(); t.urlIn.focus(); }
|
|
727
|
+
}
|
|
728
|
+
function buildBrowserTab(v, t) {
|
|
729
|
+
const body = el("div", "tabbody browser"); body.hidden = true;
|
|
730
|
+
const bar = el("div", "bar");
|
|
731
|
+
const reload = el("button", "iconbtn", "↻"); reload.title = "reload";
|
|
732
|
+
const urlIn = document.createElement("input"); urlIn.placeholder = "http://localhost:5173"; urlIn.spellcheck = false;
|
|
733
|
+
const dlId = "dl_" + t.id; const dl = document.createElement("datalist"); dl.id = dlId; urlIn.setAttribute("list", dlId);
|
|
734
|
+
const go = el("button", "iconbtn", "→"); go.title = "go";
|
|
735
|
+
const ext = el("a", "iconbtn", "↗"); ext.title = "open in a new browser tab"; ext.target = "_blank"; ext.rel = "noopener";
|
|
736
|
+
bar.appendChild(reload); bar.appendChild(urlIn); bar.appendChild(dl); bar.appendChild(go); bar.appendChild(ext);
|
|
737
|
+
const empty = el("div", "empty");
|
|
738
|
+
empty.appendChild(el("div", "", "Enter a URL to preview it here."));
|
|
739
|
+
const urlsEl = el("div", "urls"); empty.appendChild(urlsEl);
|
|
740
|
+
const frame = document.createElement("iframe"); frame.hidden = true;
|
|
741
|
+
// Games need pointer lock; confirmation dialogs must reach the user.
|
|
742
|
+
// Keep navigation to the harness origin blocked below.
|
|
743
|
+
frame.setAttribute("sandbox", "allow-scripts allow-forms allow-same-origin allow-popups allow-pointer-lock allow-modals");
|
|
744
|
+
frame.referrerPolicy = "no-referrer";
|
|
745
|
+
frame.title = "App preview";
|
|
746
|
+
body.appendChild(bar); body.appendChild(empty); body.appendChild(frame);
|
|
747
|
+
v.panelEl.appendChild(body);
|
|
748
|
+
t.el = body; t.frame = frame; t.urlIn = urlIn; t.dl = dl; t.urlsEl = urlsEl;
|
|
749
|
+
t.nav = (u) => {
|
|
750
|
+
u = (u || "").trim();
|
|
751
|
+
if (!u) return;
|
|
752
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) u = "http://" + u;
|
|
753
|
+
let parsed;
|
|
754
|
+
try { parsed = new URL(u); } catch { urlIn.setCustomValidity("Enter a valid http or https URL"); urlIn.reportValidity(); return; }
|
|
755
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.origin === location.origin || (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) && parsed.port === location.port)) {
|
|
756
|
+
urlIn.setCustomValidity("Preview a separate app using http or https"); urlIn.reportValidity(); return;
|
|
757
|
+
}
|
|
758
|
+
urlIn.setCustomValidity(""); u = parsed.href;
|
|
759
|
+
t.url = u; urlIn.value = u; ext.href = u;
|
|
760
|
+
frame.src = u; frame.hidden = false; empty.hidden = true;
|
|
761
|
+
savePanel(v); renderPanel();
|
|
762
|
+
};
|
|
763
|
+
go.onclick = () => t.nav(urlIn.value);
|
|
764
|
+
urlIn.onkeydown = (e) => { if (e.key === "Enter") t.nav(urlIn.value); };
|
|
765
|
+
reload.onclick = () => { if (t.url) frame.src = t.url; };
|
|
766
|
+
fillUrls(t, v.state.urls || []);
|
|
767
|
+
if (t.url) t.nav(t.url);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// terminal tabs
|
|
771
|
+
function ensureTermTab(v, tid, cwd) {
|
|
772
|
+
let t = v.terms.get(tid);
|
|
773
|
+
if (t) { if (cwd) setPrompt(t, cwd); return t; }
|
|
774
|
+
t = { kind: "term", tid, id: "t:" + tid, cwd: cwd || "", history: [], hi: 0, lines: 0, cur: null };
|
|
775
|
+
const body = el("div", "tabbody term"); body.hidden = true;
|
|
776
|
+
const out = el("pre", "out");
|
|
777
|
+
const row = el("div", "trow");
|
|
778
|
+
const prompt = el("span", "prompt");
|
|
779
|
+
const inp = document.createElement("input");
|
|
780
|
+
inp.placeholder = "Run a command…"; inp.title = "Enter to run · Ctrl+C to interrupt · Ctrl+L to clear"; inp.setAttribute("aria-label", "Terminal command"); inp.spellcheck = false; inp.autocomplete = "off";
|
|
781
|
+
inp.onkeydown = (e) => {
|
|
782
|
+
if (e.key === "Enter") {
|
|
783
|
+
const text = inp.value;
|
|
784
|
+
if (!text.trim()) return;
|
|
785
|
+
inp.value = "";
|
|
786
|
+
if (t.history[t.history.length - 1] !== text) t.history.push(text);
|
|
787
|
+
t.hi = t.history.length;
|
|
788
|
+
post("/term/input", { sid: v.sid, tid, text });
|
|
789
|
+
} else if (e.key === "c" && e.ctrlKey && !String(window.getSelection())) { e.preventDefault(); post("/term/interrupt", { sid: v.sid, tid }); }
|
|
790
|
+
else if (e.key === "l" && e.ctrlKey) { e.preventDefault(); out.innerHTML = ""; t.cur = null; t.lines = 0; }
|
|
791
|
+
else if (e.key === "ArrowUp") { if (t.hi > 0) { t.hi--; inp.value = t.history[t.hi]; } e.preventDefault(); }
|
|
792
|
+
else if (e.key === "ArrowDown") { if (t.hi < t.history.length - 1) { t.hi++; inp.value = t.history[t.hi]; } else { t.hi = t.history.length; inp.value = ""; } e.preventDefault(); }
|
|
793
|
+
};
|
|
794
|
+
out.onclick = () => { if (!String(window.getSelection())) inp.focus(); };
|
|
795
|
+
row.appendChild(prompt); row.appendChild(inp);
|
|
796
|
+
body.appendChild(out); body.appendChild(row);
|
|
797
|
+
v.panelEl.appendChild(body);
|
|
798
|
+
t.el = body; t.out = out; t.inp = inp; t.promptEl = prompt;
|
|
799
|
+
setPrompt(t, cwd || "");
|
|
800
|
+
v.terms.set(tid, t); v.tabs.push(t);
|
|
801
|
+
if (!v.activeTab) v.activeTab = t.id;
|
|
802
|
+
if (v === active) renderPanel();
|
|
803
|
+
return t;
|
|
804
|
+
}
|
|
805
|
+
function setPrompt(t, cwd) { t.cwd = cwd; t.promptEl.textContent = (shortPath(cwd) || "…") + " ❯"; t.promptEl.title = cwd; }
|
|
806
|
+
function termWrite(v, tid, text) {
|
|
807
|
+
const t = v.terms.get(tid) || ensureTermTab(v, tid, "");
|
|
808
|
+
const out = t.out;
|
|
809
|
+
const nearBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 60;
|
|
810
|
+
const parts = text.split("\n");
|
|
811
|
+
for (let i = 0; i < parts.length; i++) {
|
|
812
|
+
let seg = parts[i];
|
|
813
|
+
if (i > 0) t.cur = null;
|
|
814
|
+
if (!t.cur) { t.cur = el("div", "l"); out.appendChild(t.cur); t.lines++; }
|
|
815
|
+
const cr = seg.lastIndexOf("\r");
|
|
816
|
+
if (cr >= 0) { t.cur.innerHTML = ""; seg = seg.slice(cr + 1); }
|
|
817
|
+
if (seg) t.cur.appendChild(ansiToFrag(seg));
|
|
818
|
+
}
|
|
819
|
+
while (t.lines > 4000 && out.firstChild) { out.removeChild(out.firstChild); t.lines--; }
|
|
820
|
+
if (nearBottom) out.scrollTop = out.scrollHeight;
|
|
821
|
+
}
|
|
822
|
+
function termDone(v, tid, code, cwd) {
|
|
823
|
+
const t = v.terms.get(tid);
|
|
824
|
+
if (!t) return;
|
|
825
|
+
if (cwd) setPrompt(t, cwd);
|
|
826
|
+
if (t.cur && t.cur.textContent) termWrite(v, tid, "\n");
|
|
827
|
+
if (code) termWrite(v, tid, "\x1b[2m[exit " + code + "]\x1b[0m\n");
|
|
828
|
+
}
|
|
829
|
+
function removeTermTab(v, tid) {
|
|
830
|
+
const t = v.terms.get(tid);
|
|
831
|
+
if (!t) return;
|
|
832
|
+
v.terms.delete(tid);
|
|
833
|
+
const i = v.tabs.indexOf(t);
|
|
834
|
+
if (i >= 0) v.tabs.splice(i, 1);
|
|
835
|
+
if (t.el) t.el.remove();
|
|
836
|
+
if (v.activeTab === t.id) v.activeTab = (v.tabs[i] || v.tabs[i - 1] || {}).id || null;
|
|
837
|
+
savePanel(v);
|
|
838
|
+
if (v === active) renderPanel();
|
|
839
|
+
}
|
|
840
|
+
function reconcileTerms(v) {
|
|
841
|
+
const info = sessInfo.get(v.sid);
|
|
842
|
+
const alive = new Set(info && info.live ? info.terminals.map((x) => x.tid) : []);
|
|
843
|
+
for (const tid of [...v.terms.keys()]) if (!alive.has(tid)) removeTermTab(v, tid);
|
|
844
|
+
if (info && info.live) for (const x of info.terminals) ensureTermTab(v, x.tid, x.cwd);
|
|
845
|
+
}
|
|
846
|
+
function openTerminalTab(v) {
|
|
847
|
+
post("/term/open", { sid: v.sid }).then((r) => {
|
|
848
|
+
if (!r.tid) { if (r.error) alert(r.error); return; }
|
|
849
|
+
const t = ensureTermTab(v, r.tid, r.cwd);
|
|
850
|
+
v.activeTab = t.id; v.panelOpen = true; savePanel(v); renderPanel();
|
|
851
|
+
t.inp.focus();
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// resize grip
|
|
856
|
+
$("panelgrip").onmousedown = (e) => {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
document.body.classList.add("dragging");
|
|
859
|
+
const move = (ev) => { panelWidth = Math.min(window.innerWidth * 0.8, Math.max(300, window.innerWidth - ev.clientX)); panelEl.style.width = panelWidth + "px"; };
|
|
860
|
+
const up = () => { document.body.classList.remove("dragging"); document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); ls.set("smol.panel.w", String(Math.round(panelWidth))); };
|
|
861
|
+
document.addEventListener("mousemove", move);
|
|
862
|
+
document.addEventListener("mouseup", up);
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// ---- sidebar collapse -----------------------------------------------------
|
|
866
|
+
function setSide(collapsed) {
|
|
867
|
+
sideEl.classList.toggle("collapsed", collapsed);
|
|
868
|
+
$("sidetoggle").hidden = !collapsed;
|
|
869
|
+
ls.set("smol.side", collapsed ? "1" : "0");
|
|
870
|
+
}
|
|
871
|
+
function narrow() { return window.matchMedia("(max-width: 1000px)").matches; }
|
|
872
|
+
$("sidecollapse").onclick = () => setSide(true);
|
|
873
|
+
$("sidetoggle").onclick = () => setSide(false);
|
|
874
|
+
setSide(ls.get("smol.side") === "1" || (narrow() && !!location.hash));
|
|
875
|
+
|
|
876
|
+
// ---- input + slash menu ---------------------------------------------------
|
|
877
|
+
let menuIdx = 0;
|
|
878
|
+
function menuItems() {
|
|
879
|
+
const v = input.value;
|
|
880
|
+
if (!active || !v.startsWith("/") || v.includes(" ") || v.includes("\n")) return [];
|
|
881
|
+
return (active.state.commands || []).filter((c) => c.name.startsWith(v.slice(1)));
|
|
882
|
+
}
|
|
883
|
+
function renderMenu() {
|
|
884
|
+
const items = menuItems();
|
|
885
|
+
menu.style.display = items.length ? "block" : "none";
|
|
886
|
+
menu.innerHTML = "";
|
|
887
|
+
if (menuIdx >= items.length) menuIdx = 0;
|
|
888
|
+
items.forEach((c, i) => {
|
|
889
|
+
const d = el("div", "item" + (i === menuIdx ? " sel" : ""));
|
|
890
|
+
d.appendChild(el("span", "nm", "/" + c.name)); d.appendChild(el("span", "ds", c.desc));
|
|
891
|
+
d.onclick = () => { input.value = "/" + c.name; submit(); };
|
|
892
|
+
menu.appendChild(d);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function submit() {
|
|
896
|
+
if (!active) return;
|
|
897
|
+
let v = input.value;
|
|
898
|
+
const items = menuItems();
|
|
899
|
+
if (items.length) v = "/" + items[menuIdx].name;
|
|
900
|
+
v = v.trim();
|
|
901
|
+
const pending = pendingOf(active);
|
|
902
|
+
if (pending.some((a) => a.uploading)) return; // let the upload finish first
|
|
903
|
+
const files = pending.filter((a) => a.id).map((a) => a.id);
|
|
904
|
+
if (!v && !files.length) return;
|
|
905
|
+
input.value = ""; active.draft = ""; active.pending = []; renderMenu(); autoGrow(); renderAttachments();
|
|
906
|
+
scrollToBottom();
|
|
907
|
+
post("/msg", { sid: active.sid, text: v, attachments: files }).then((r) => { if (r && r.error) alert(r.error); });
|
|
908
|
+
}
|
|
909
|
+
function autoGrow() { input.rows = Math.min(6, Math.max(1, input.value.split("\n").length)); }
|
|
910
|
+
input.addEventListener("input", () => { menuIdx = 0; renderMenu(); autoGrow(); });
|
|
911
|
+
input.addEventListener("keydown", (e) => {
|
|
912
|
+
const items = menuItems();
|
|
913
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
|
914
|
+
else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); if (active) post("/cycle", { sid: active.sid }); }
|
|
915
|
+
else if (e.key === "Tab" && items.length) { e.preventDefault(); input.value = "/" + items[menuIdx].name + " "; renderMenu(); }
|
|
916
|
+
else if (e.key === "ArrowUp" && items.length) { e.preventDefault(); menuIdx = (menuIdx - 1 + items.length) % items.length; renderMenu(); }
|
|
917
|
+
else if (e.key === "ArrowDown" && items.length) { e.preventDefault(); menuIdx = (menuIdx + 1) % items.length; renderMenu(); }
|
|
918
|
+
else if (e.key === "Escape") { if (input.value) { input.value = ""; renderMenu(); autoGrow(); } else if (active) post("/cancel", { sid: active.sid }); }
|
|
919
|
+
});
|
|
920
|
+
actionBtn.onclick = () => { if (!active) return; if (active.busyLabel) post("/cancel", { sid: active.sid }); else submit(); };
|
|
921
|
+
|
|
922
|
+
// ---- attachments ----------------------------------------------------------
|
|
923
|
+
// Paste a screenshot (ctrl+v or right-click → paste), drop files on the chat,
|
|
924
|
+
// or pick them with the paperclip. Each file is uploaded right away and shown
|
|
925
|
+
// as a chip; the ids go with the next message.
|
|
926
|
+
const attachRow = $("attachrow"), filePick = $("filepick"), mainEl = $("main");
|
|
927
|
+
function pendingOf(v) { if (!v.pending) v.pending = []; return v.pending; }
|
|
928
|
+
function renderAttachments() {
|
|
929
|
+
attachRow.innerHTML = "";
|
|
930
|
+
const list = active ? pendingOf(active) : [];
|
|
931
|
+
attachRow.hidden = !list.length;
|
|
932
|
+
for (const a of list) {
|
|
933
|
+
const chip = el("span", "attach" + (a.uploading ? " uploading" : "") + (a.warning ? " warn" : ""));
|
|
934
|
+
if (a.kind === "image" && a.url) { const img = el("img", "attach-thumb"); img.src = a.url + "&k=" + k; img.alt = ""; chip.appendChild(img); }
|
|
935
|
+
chip.appendChild(el("span", "attach-name", a.name));
|
|
936
|
+
chip.appendChild(el("span", "dim", a.uploading ? "uploading…" : fmtSize(a.size)));
|
|
937
|
+
if (a.warning) { chip.title = a.warning; chip.appendChild(el("span", "note", "model can't see images")); }
|
|
938
|
+
const x = el("button", "attach-x", "×"); x.type = "button"; x.title = "remove"; x.onclick = () => removeAttachment(a);
|
|
939
|
+
chip.appendChild(x);
|
|
940
|
+
attachRow.appendChild(chip);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function addFiles(files) {
|
|
944
|
+
if (!active) return;
|
|
945
|
+
for (const f of files) upload(active, f);
|
|
946
|
+
input.focus();
|
|
947
|
+
}
|
|
948
|
+
async function upload(v, file) {
|
|
949
|
+
const type = file.type || "application/octet-stream";
|
|
950
|
+
const name = file.name || (type.startsWith("image/") ? "pasted-image." + (type.split("/")[1] || "png").replace("jpeg", "jpg") : "pasted.txt");
|
|
951
|
+
const entry = { id: null, name, size: file.size, kind: type.startsWith("image/") ? "image" : "text", uploading: true };
|
|
952
|
+
pendingOf(v).push(entry); renderAttachments();
|
|
953
|
+
try {
|
|
954
|
+
const r = await fetch("/upload?k=" + k + "&sid=" + v.sid + "&name=" + encodeURIComponent(name), { method: "POST", headers: { "content-type": type }, body: file });
|
|
955
|
+
const d = await r.json().catch(() => ({}));
|
|
956
|
+
if (!r.ok || d.error) throw new Error(d.error || ("upload failed (" + r.status + ")"));
|
|
957
|
+
Object.assign(entry, d, { uploading: false });
|
|
958
|
+
} catch (err) {
|
|
959
|
+
const list = pendingOf(v); const i = list.indexOf(entry); if (i >= 0) list.splice(i, 1);
|
|
960
|
+
alert(String((err && err.message) || err));
|
|
961
|
+
}
|
|
962
|
+
renderAttachments();
|
|
963
|
+
}
|
|
964
|
+
function removeAttachment(a) {
|
|
965
|
+
if (!active) return;
|
|
966
|
+
const list = pendingOf(active); const i = list.indexOf(a); if (i >= 0) list.splice(i, 1);
|
|
967
|
+
if (a.id) post("/upload/remove", { sid: active.sid, id: a.id });
|
|
968
|
+
renderAttachments();
|
|
969
|
+
}
|
|
970
|
+
$("attachbtn").onclick = () => { if (active) filePick.click(); };
|
|
971
|
+
filePick.onchange = () => { addFiles([...filePick.files]); filePick.value = ""; };
|
|
972
|
+
input.addEventListener("paste", (e) => {
|
|
973
|
+
const files = e.clipboardData && e.clipboardData.files ? [...e.clipboardData.files] : [];
|
|
974
|
+
if (!files.length) return; // plain text pastes as usual
|
|
975
|
+
e.preventDefault();
|
|
976
|
+
addFiles(files);
|
|
977
|
+
});
|
|
978
|
+
function hasFiles(e) { return !!(e.dataTransfer && [...e.dataTransfer.types].includes("Files")); }
|
|
979
|
+
mainEl.addEventListener("dragover", (e) => { if (!active || !hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; mainEl.classList.add("dragging"); });
|
|
980
|
+
mainEl.addEventListener("dragleave", (e) => { if (e.relatedTarget && mainEl.contains(e.relatedTarget)) return; mainEl.classList.remove("dragging"); });
|
|
981
|
+
mainEl.addEventListener("drop", (e) => { mainEl.classList.remove("dragging"); if (!active || !hasFiles(e)) return; e.preventDefault(); addFiles([...e.dataTransfer.files]); });
|
|
982
|
+
|
|
983
|
+
// ---- global keys ----------------------------------------------------------
|
|
984
|
+
$("keys").onclick = () => {
|
|
985
|
+
const dialog = document.createElement("dialog"); dialog.className = "dlg";
|
|
986
|
+
const list = el("div", "shortcut-list");
|
|
987
|
+
["/ Commands", "Enter Send", "Shift+Enter New line", "Ctrl+V Paste an image or a file", "Shift+Tab Permission mode", "Esc Cancel", "Ctrl+B Sidebar", "Ctrl+\` Terminal"].forEach((s) => list.appendChild(el("div", "", s)));
|
|
988
|
+
const close = el("button", "ghost", "Close"); close.onclick = () => dialog.close(); list.appendChild(close);
|
|
989
|
+
dialog.appendChild(list); document.body.appendChild(dialog); dialog.onclose = () => dialog.remove(); dialog.showModal();
|
|
990
|
+
};
|
|
991
|
+
document.addEventListener("keydown", (e) => {
|
|
992
|
+
if (e.key === "Escape" && document.activeElement !== input) {
|
|
993
|
+
if (!$("modal").hidden) closeDialog();
|
|
994
|
+
else if (active && !(document.activeElement && document.activeElement.closest && document.activeElement.closest(".tabbody.term"))) post("/cancel", { sid: active.sid });
|
|
995
|
+
} else if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key.toLowerCase() === "b") { e.preventDefault(); setSide(!sideEl.classList.contains("collapsed")); }
|
|
996
|
+
else if (e.ctrlKey && e.key === "\`") { e.preventDefault(); togglePanelKind("term"); }
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// ---- connect --------------------------------------------------------------
|
|
1000
|
+
const es = new EventSource("/events?k=" + k);
|
|
1001
|
+
es.onopen = () => {
|
|
1002
|
+
// Everything is replayed on (re)connect: start each view from a clean slate.
|
|
1003
|
+
for (const v of views.values()) {
|
|
1004
|
+
v.logEl.innerHTML = ""; v.curText = null; v.curThought = null; v.asks.clear();
|
|
1005
|
+
for (const t of v.terms.values()) { t.out.innerHTML = ""; t.cur = null; t.lines = 0; }
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
es.onmessage = (e) => handle(JSON.parse(e.data));
|
|
1009
|
+
es.onerror = () => { $("status").textContent = "reconnecting…"; };
|
|
1010
|
+
`;
|