smolcoder 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/dist/agent.js +9 -0
- package/dist/config.js +72 -0
- package/dist/index.js +79 -390
- package/dist/session.js +480 -0
- package/dist/tools/tasks.js +15 -0
- package/dist/web/channel.js +222 -0
- package/dist/web/client.js +815 -0
- package/dist/web/hub.js +786 -0
- package/dist/web/page.js +73 -366
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +222 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +1 -1
|
@@ -0,0 +1,815 @@
|
|
|
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 input = $("input"), menu = $("menu"), sideEl = $("side"), panelEl = $("panel"), tabsEl = $("paneltabs");
|
|
22
|
+
|
|
23
|
+
let hub = { workspaces: [], home: "", version: "" };
|
|
24
|
+
const sessInfo = new Map(); // sid -> sidebar entry from the last hub snapshot
|
|
25
|
+
const views = new Map(); // sid -> per-session view state
|
|
26
|
+
let active = null;
|
|
27
|
+
let pendingSelect = null;
|
|
28
|
+
let busyTimer = null;
|
|
29
|
+
let stickBottom = true;
|
|
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, asks: new Map(),
|
|
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
|
+
function stick() { if (stickBottom) logwrap.scrollTop = logwrap.scrollHeight; }
|
|
179
|
+
logwrap.addEventListener("scroll", () => { stickBottom = logwrap.scrollHeight - logwrap.scrollTop - logwrap.clientHeight < 120; });
|
|
180
|
+
function add(v, e) { v.logEl.appendChild(e); if (v === active) stick(); return e; }
|
|
181
|
+
// Streaming: accumulate raw markdown on the element, re-render on a short
|
|
182
|
+
// timer. NOT requestAnimationFrame: rAF never fires in background tabs, so a
|
|
183
|
+
// response streamed while the tab is hidden would never render.
|
|
184
|
+
function scheduleMd(v, target) {
|
|
185
|
+
if (target._pending) return;
|
|
186
|
+
target._pending = true;
|
|
187
|
+
setTimeout(() => { target._pending = false; target.innerHTML = renderMarkdown(target._raw || ""); if (v === active) stick(); }, 60);
|
|
188
|
+
}
|
|
189
|
+
function endThought(v) {
|
|
190
|
+
if (v.curThought) {
|
|
191
|
+
v.curThought.textContent = "✦ thought for " + ((Date.now() - v.thoughtStart) / 1000).toFixed(1) + "s";
|
|
192
|
+
v.curThought = null; v.thoughtBuf = "";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setBusy(v, label) {
|
|
197
|
+
v.busyLabel = label || null;
|
|
198
|
+
if (label) v.busyStart = Date.now();
|
|
199
|
+
if (v === active) renderBusy();
|
|
200
|
+
}
|
|
201
|
+
function renderBusy() {
|
|
202
|
+
const label = active ? active.busyLabel : null;
|
|
203
|
+
actionBtn.textContent = label ? "■ stop" : "send";
|
|
204
|
+
actionBtn.className = label ? "stop" : "";
|
|
205
|
+
actionBtn.title = label ? "interrupt the agent (esc)" : "send (enter)";
|
|
206
|
+
const tick = () => {
|
|
207
|
+
const s = active && active.busyLabel ? Math.floor((Date.now() - active.busyStart) / 1000) : 0;
|
|
208
|
+
$("busysecs").textContent = s > 2 ? s + "s" : "";
|
|
209
|
+
};
|
|
210
|
+
if (label) {
|
|
211
|
+
busyEl.classList.add("on");
|
|
212
|
+
$("busylabel").textContent = label + "…";
|
|
213
|
+
if (!busyTimer) busyTimer = setInterval(tick, 500);
|
|
214
|
+
tick();
|
|
215
|
+
} else {
|
|
216
|
+
busyEl.classList.remove("on");
|
|
217
|
+
if (busyTimer) { clearInterval(busyTimer); busyTimer = null; }
|
|
218
|
+
$("busysecs").textContent = "";
|
|
219
|
+
}
|
|
220
|
+
stick();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderState(v) {
|
|
224
|
+
for (const t of v.tabs) if (t.kind === "browser") fillUrls(t, v.state.urls || []);
|
|
225
|
+
if (v !== active) return;
|
|
226
|
+
const s = v.state;
|
|
227
|
+
const st = $("status");
|
|
228
|
+
st.innerHTML = "";
|
|
229
|
+
if (!s.mode) { st.textContent = "starting…"; renderCrumb(); return; }
|
|
230
|
+
const mode = el("span", "mode " + s.mode, s.mode === "ro" ? "read-only" : s.mode === "bypass" ? "bypass permissions" : s.mode);
|
|
231
|
+
st.appendChild(mode);
|
|
232
|
+
st.appendChild(document.createTextNode(" · " + s.model + " (" + s.backend + ")"));
|
|
233
|
+
if (s.effort) { st.appendChild(document.createTextNode(" · ")); st.appendChild(el("span", "eff", s.effort)); }
|
|
234
|
+
const kt = s.ctxTokens < 1000 ? s.ctxTokens : (s.ctxTokens / 1000).toFixed(1) + "k";
|
|
235
|
+
st.appendChild(document.createTextNode(" · " + kt + " (" + s.ctxPct + "%)"));
|
|
236
|
+
if (s.plan) {
|
|
237
|
+
st.appendChild(document.createTextNode(" · "));
|
|
238
|
+
const done = s.plan.steps.filter((x) => x.done).length;
|
|
239
|
+
st.appendChild(el("span", "plan-chip" + (s.plan.current < 0 ? " done" : ""), "plan " + done + "/" + s.plan.steps.length));
|
|
240
|
+
}
|
|
241
|
+
if (s.tasks) st.appendChild(document.createTextNode(" · " + s.tasks + " task" + (s.tasks > 1 ? "s" : "")));
|
|
242
|
+
$("ws").textContent = shortPath(s.workspace || "");
|
|
243
|
+
renderCrumb();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderPlan(v, p) {
|
|
247
|
+
const box = el("div", "plan");
|
|
248
|
+
const done = p.steps.filter((x) => x.done).length;
|
|
249
|
+
const hdr = el("div", "hdr", "Plan ");
|
|
250
|
+
hdr.appendChild(el("small", "", done + "/" + p.steps.length));
|
|
251
|
+
box.appendChild(hdr);
|
|
252
|
+
p.steps.forEach((s, i) => {
|
|
253
|
+
const cls = s.done ? "done" : i === p.current ? "cur" : "todo";
|
|
254
|
+
const mark = s.done ? "✔ " : i === p.current ? "▶ " : "○ ";
|
|
255
|
+
box.appendChild(el("div", cls, mark + s.text));
|
|
256
|
+
});
|
|
257
|
+
add(v, box);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- event handling -------------------------------------------------------
|
|
261
|
+
function handle(m) {
|
|
262
|
+
if (m.t === "hub") { onHub(m); return; }
|
|
263
|
+
if (m.t === "closed") { dropView(m.sid); return; }
|
|
264
|
+
if (!m.sid) return;
|
|
265
|
+
const v = getView(m.sid);
|
|
266
|
+
switch (m.t) {
|
|
267
|
+
case "state":
|
|
268
|
+
v.state = Object.assign(v.state, m.s);
|
|
269
|
+
if (m.s && "busy" in m.s) setBusy(v, m.s.busy);
|
|
270
|
+
renderState(v); break;
|
|
271
|
+
case "user": endThought(v); v.curText = null; add(v, el("div", "user", m.s)); break;
|
|
272
|
+
case "token":
|
|
273
|
+
endThought(v);
|
|
274
|
+
if (!v.curText) { v.curText = add(v, el("div", "md")); v.curText._raw = ""; }
|
|
275
|
+
v.curText._raw += m.s; scheduleMd(v, v.curText); break;
|
|
276
|
+
case "thinking":
|
|
277
|
+
if (!v.curThought) { v.curThought = add(v, el("div", "thought")); v.thoughtStart = Date.now(); v.thoughtBuf = ""; v.curText = null; }
|
|
278
|
+
v.thoughtBuf += m.s;
|
|
279
|
+
if (v.thoughtBuf.length > 4000) v.thoughtBuf = v.thoughtBuf.slice(-2000);
|
|
280
|
+
var tt = v.thoughtBuf.replace(/\s+/g, " ").trim();
|
|
281
|
+
v.curThought.textContent = "✦ " + (tt.length > 160 ? "…" + tt.slice(-160) : tt);
|
|
282
|
+
if (v === active) stick(); break;
|
|
283
|
+
case "tool": {
|
|
284
|
+
endThought(v); v.curText = null;
|
|
285
|
+
const d = el("div", "tool"); d.appendChild(el("span", "name", "→ " + m.name)); d.appendChild(document.createTextNode(" " + (m.summary || "")));
|
|
286
|
+
add(v, d); break;
|
|
287
|
+
}
|
|
288
|
+
case "result": endThought(v); v.curText = null; add(v, el("div", "result" + (m.err ? " err" : ""), (m.err ? "✗ " : "✓ ") + m.line + (m.extra ? " (+" + m.extra + " lines)" : ""))); break;
|
|
289
|
+
case "plan": endThought(v); v.curText = null; renderPlan(v, m); break;
|
|
290
|
+
case "line": endThought(v); v.curText = null; add(v, el("div", "line-" + m.kind, m.s)); break;
|
|
291
|
+
case "turnend":
|
|
292
|
+
endThought(v); v.curText = null; add(v, el("div", "turnend", "■ " + m.label));
|
|
293
|
+
if (v !== active) { v.unread = true; renderSidebar(); }
|
|
294
|
+
break;
|
|
295
|
+
case "busy": setBusy(v, m.label); break;
|
|
296
|
+
case "confirm": {
|
|
297
|
+
endThought(v); v.curText = null;
|
|
298
|
+
const box = el("div", "ask");
|
|
299
|
+
box.appendChild(el("div", "", "run?")); box.appendChild(el("div", "cmd", m.command));
|
|
300
|
+
if (m.reason) box.appendChild(el("div", "hint", m.reason));
|
|
301
|
+
["yes", "no", "always"].forEach((a) => {
|
|
302
|
+
const b = el("button", "", a === "always" ? "always allow this program" : a);
|
|
303
|
+
b.onclick = () => { post("/confirm", { sid: v.sid, id: m.id, answer: a }); box.remove(); };
|
|
304
|
+
box.appendChild(b);
|
|
305
|
+
});
|
|
306
|
+
v.asks.set(m.id, box);
|
|
307
|
+
add(v, box); break;
|
|
308
|
+
}
|
|
309
|
+
case "answered": {
|
|
310
|
+
// The server settled this prompt (answered here, elsewhere, or cancelled).
|
|
311
|
+
const box = v.asks.get(m.id);
|
|
312
|
+
if (box) { box.remove(); v.asks.delete(m.id); }
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "select": {
|
|
316
|
+
endThought(v); v.curText = null;
|
|
317
|
+
const box = el("div", "ask");
|
|
318
|
+
box.appendChild(el("div", "cmd", m.title));
|
|
319
|
+
m.options.forEach((o, i) => {
|
|
320
|
+
const b = el("button", "opt" + (o.current ? " current" : ""), (o.current ? "● " : "") + o.label);
|
|
321
|
+
if (o.hint) b.appendChild(el("span", "hint", o.hint));
|
|
322
|
+
b.onclick = () => { post("/select", { sid: v.sid, id: m.id, index: i }); box.remove(); };
|
|
323
|
+
box.appendChild(el("div")).appendChild(b);
|
|
324
|
+
});
|
|
325
|
+
const cancel = el("button", "", "cancel");
|
|
326
|
+
cancel.onclick = () => { post("/select", { sid: v.sid, id: m.id, index: null }); box.remove(); };
|
|
327
|
+
box.appendChild(cancel);
|
|
328
|
+
v.asks.set(m.id, box);
|
|
329
|
+
add(v, box); break;
|
|
330
|
+
}
|
|
331
|
+
case "termopen": ensureTermTab(v, m.tid, m.cwd); break;
|
|
332
|
+
case "term": termWrite(v, m.tid, m.s); break;
|
|
333
|
+
case "termdone": termDone(v, m.tid, m.code, m.cwd); break;
|
|
334
|
+
case "termclosed": removeTermTab(v, m.tid); break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- hub snapshot + sidebar ------------------------------------------------
|
|
339
|
+
function onHub(m) {
|
|
340
|
+
hub = m;
|
|
341
|
+
sessInfo.clear();
|
|
342
|
+
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));
|
|
343
|
+
for (const v of views.values()) reconcileTerms(v);
|
|
344
|
+
$("ver").textContent = "v" + m.version;
|
|
345
|
+
if (pendingSelect && sessInfo.has(pendingSelect)) { const id = pendingSelect; pendingSelect = null; show(id); }
|
|
346
|
+
else if (!active) {
|
|
347
|
+
const h = location.hash.slice(1);
|
|
348
|
+
if (h && sessInfo.has(h)) select(h);
|
|
349
|
+
else {
|
|
350
|
+
const live = [...sessInfo.values()].filter((s) => s.live).sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
351
|
+
show(live ? live.id : null);
|
|
352
|
+
}
|
|
353
|
+
} else if (!sessInfo.has(active.sid) && !pendingSelect) show(null);
|
|
354
|
+
renderSidebar(); renderCrumb(); renderTitle(); renderWelcome();
|
|
355
|
+
}
|
|
356
|
+
function select(id) {
|
|
357
|
+
const s = sessInfo.get(id);
|
|
358
|
+
if (s && (!s.live || s.status === "error")) post("/sessions/resume", { id });
|
|
359
|
+
show(id);
|
|
360
|
+
}
|
|
361
|
+
function show(sid) {
|
|
362
|
+
if (active) {
|
|
363
|
+
active.draft = input.value; active.scrollTop = logwrap.scrollTop;
|
|
364
|
+
active.logEl.hidden = true;
|
|
365
|
+
}
|
|
366
|
+
active = sid ? getView(sid) : null;
|
|
367
|
+
$("welcome").hidden = !!active;
|
|
368
|
+
$("bottom").hidden = !active;
|
|
369
|
+
busyEl.hidden = !active;
|
|
370
|
+
if (active) {
|
|
371
|
+
active.logEl.hidden = false; active.unread = false;
|
|
372
|
+
input.value = active.draft || ""; autoGrow(); menuIdx = 0; renderMenu();
|
|
373
|
+
renderState(active); renderBusy();
|
|
374
|
+
stickBottom = true;
|
|
375
|
+
logwrap.scrollTop = active.scrollTop == null ? logwrap.scrollHeight : active.scrollTop;
|
|
376
|
+
if (location.hash !== "#" + sid) history.replaceState(null, "", "#" + sid);
|
|
377
|
+
// On a narrow window the sidebar floats over the chat: tuck it away once
|
|
378
|
+
// a session is picked.
|
|
379
|
+
if (narrow()) setSide(true);
|
|
380
|
+
input.focus();
|
|
381
|
+
} else {
|
|
382
|
+
if (location.hash) history.replaceState(null, "", location.pathname + location.search);
|
|
383
|
+
$("status").textContent = "";
|
|
384
|
+
}
|
|
385
|
+
renderPanel(); renderSidebar(); renderCrumb(); renderTitle(); renderWelcome();
|
|
386
|
+
}
|
|
387
|
+
function newSession(path) {
|
|
388
|
+
post("/sessions/new", { workspace: path }).then((r) => { if (r.id) { pendingSelect = r.id; show(r.id); } else if (r.error) alert(r.error); });
|
|
389
|
+
}
|
|
390
|
+
function renderSidebar() {
|
|
391
|
+
const list = $("wslist");
|
|
392
|
+
list.innerHTML = "";
|
|
393
|
+
if (!hub.workspaces.length) { list.appendChild(el("div", "sidehint", "No workspaces yet. Open a folder to start your first session.")); return; }
|
|
394
|
+
for (const w of hub.workspaces) {
|
|
395
|
+
const box = el("div", "ws");
|
|
396
|
+
const hdr = el("div", "wshdr"); hdr.title = w.path;
|
|
397
|
+
hdr.appendChild(el("span", "wsname", w.name)); hdr.appendChild(el("span", "wspath", w.display));
|
|
398
|
+
const plus = el("button", "iconbtn", "+"); plus.title = "new session in " + w.name;
|
|
399
|
+
plus.onclick = (e) => { e.stopPropagation(); newSession(w.path); };
|
|
400
|
+
const rm = el("button", "iconbtn", "×"); rm.title = "remove " + w.name + " from the list";
|
|
401
|
+
rm.onclick = (e) => { e.stopPropagation(); removeWorkspace(w); };
|
|
402
|
+
hdr.appendChild(plus); hdr.appendChild(rm);
|
|
403
|
+
if (!w.sessions.length) { hdr.style.cursor = "pointer"; hdr.onclick = () => newSession(w.path); }
|
|
404
|
+
box.appendChild(hdr);
|
|
405
|
+
const sl = el("div", "sessions");
|
|
406
|
+
for (const s of w.sessions) {
|
|
407
|
+
const v = views.get(s.id);
|
|
408
|
+
const row = el("div", "sess " + (s.live ? s.status : "stored") + (active && active.sid === s.id ? " active" : "") + (v && v.unread ? " unread" : ""));
|
|
409
|
+
row.appendChild(el("span", "dot"));
|
|
410
|
+
row.appendChild(el("span", "stitle" + (s.title ? "" : " untitled"), s.title || "new session"));
|
|
411
|
+
row.appendChild(el("span", "stime", rel(s.updatedAt)));
|
|
412
|
+
const x = el("button", "iconbtn", "×");
|
|
413
|
+
x.title = s.live ? "close session (kept in the list)" : "delete session";
|
|
414
|
+
x.onclick = (e) => {
|
|
415
|
+
e.stopPropagation();
|
|
416
|
+
if (s.live) post("/sessions/close", { id: s.id });
|
|
417
|
+
else if (confirm("Delete this session permanently?")) post("/sessions/delete", { id: s.id });
|
|
418
|
+
};
|
|
419
|
+
row.appendChild(x);
|
|
420
|
+
row.title = (s.title || "new session") + (s.model ? " · " + s.model : "") + (s.live ? " · " + s.status : " · saved — click to resume");
|
|
421
|
+
row.onclick = () => select(s.id);
|
|
422
|
+
row.ondblclick = () => { const t = prompt("Rename session", s.title || ""); if (t !== null && t.trim()) post("/sessions/rename", { id: s.id, title: t }); };
|
|
423
|
+
sl.appendChild(row);
|
|
424
|
+
}
|
|
425
|
+
box.appendChild(sl);
|
|
426
|
+
list.appendChild(box);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function removeWorkspace(w) {
|
|
430
|
+
if (w.sessions.some((s) => s.live)) { alert("Close the open sessions in " + w.name + " first."); return; }
|
|
431
|
+
const n = w.sessions.length;
|
|
432
|
+
if (!confirm("Remove " + w.name + " from the list?" + (n ? " Its " + n + " saved session" + (n > 1 ? "s" : "") + " will be deleted." : ""))) return;
|
|
433
|
+
post("/workspaces/remove", { path: w.path }).then((r) => { if (r.error) alert(r.error); });
|
|
434
|
+
}
|
|
435
|
+
function renderCrumb() {
|
|
436
|
+
const c = $("crumb");
|
|
437
|
+
c.innerHTML = "";
|
|
438
|
+
if (!active) { c.appendChild(el("span", "ws", "smolcoder")); c.title = ""; return; }
|
|
439
|
+
const info = sessInfo.get(active.sid);
|
|
440
|
+
c.appendChild(el("span", "ws", info ? info.wsname : ""));
|
|
441
|
+
c.appendChild(el("span", "sep", "›"));
|
|
442
|
+
c.appendChild(el("span", "title", active.state.title || (info && info.title) || "new session"));
|
|
443
|
+
if (active.state.model) c.appendChild(el("span", "model", active.state.model));
|
|
444
|
+
c.title = info ? info.workspace : "";
|
|
445
|
+
}
|
|
446
|
+
function renderTitle() {
|
|
447
|
+
let busy = false, waiting = false;
|
|
448
|
+
for (const s of sessInfo.values()) { if (s.status === "busy" || s.status === "starting") busy = true; if (s.status === "waiting") waiting = true; }
|
|
449
|
+
const info = active && sessInfo.get(active.sid);
|
|
450
|
+
document.title = (waiting ? "⚠ " : busy ? "● " : "") + (info ? (info.title || info.wsname) + " · " : "") + "smol";
|
|
451
|
+
}
|
|
452
|
+
function renderWelcome() {
|
|
453
|
+
const w = $("welcome");
|
|
454
|
+
w.hidden = !!active;
|
|
455
|
+
if (active) return;
|
|
456
|
+
const r = $("recent");
|
|
457
|
+
r.innerHTML = "";
|
|
458
|
+
const ws = hub.workspaces.slice(0, 8);
|
|
459
|
+
if (!ws.length) return;
|
|
460
|
+
r.appendChild(el("div", "hint", "Start a session in a recent workspace:"));
|
|
461
|
+
for (const x of ws) {
|
|
462
|
+
const b = el("button", "wsbtn");
|
|
463
|
+
b.appendChild(el("span", "", x.name)); b.appendChild(el("span", "dim", " " + x.display));
|
|
464
|
+
b.onclick = () => newSession(x.path);
|
|
465
|
+
r.appendChild(b);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
setInterval(() => { renderSidebar(); }, 30000);
|
|
469
|
+
|
|
470
|
+
// ---- folder picker --------------------------------------------------------
|
|
471
|
+
let fsState = { path: "", parent: null };
|
|
472
|
+
function openDialog(start) {
|
|
473
|
+
$("modal").hidden = false;
|
|
474
|
+
browse(start || (active && active.state.workspace) || "");
|
|
475
|
+
setTimeout(() => $("fspath").focus(), 0);
|
|
476
|
+
}
|
|
477
|
+
function closeDialog() { $("modal").hidden = true; }
|
|
478
|
+
function browse(p) {
|
|
479
|
+
fetch("/fs?k=" + k + "&path=" + encodeURIComponent(p || "")).then((r) => r.json()).then(renderFs).catch(() => {});
|
|
480
|
+
}
|
|
481
|
+
function renderFs(d) {
|
|
482
|
+
const list = $("fslist");
|
|
483
|
+
list.innerHTML = "";
|
|
484
|
+
const roots = $("fsroots");
|
|
485
|
+
roots.innerHTML = "";
|
|
486
|
+
const chip = (label, p) => { const c = el("span", "chip", label); c.onclick = () => browse(p); roots.appendChild(c); };
|
|
487
|
+
chip("~ home", d.home);
|
|
488
|
+
for (const r of d.roots || []) chip(r, r);
|
|
489
|
+
if (d.error) { $("fspath").value = d.path || ""; list.appendChild(el("div", "sidehint", d.error)); $("fsopen").disabled = true; return; }
|
|
490
|
+
$("fsopen").disabled = false;
|
|
491
|
+
fsState = d;
|
|
492
|
+
$("fspath").value = d.path;
|
|
493
|
+
$("fsopen").textContent = "Open " + (d.path.split(/[\\/]/).filter(Boolean).pop() || d.path) + (d.project ? " ✦" : "");
|
|
494
|
+
if (d.parent) { const up = el("div", "fsitem up", "↑ .."); up.onclick = () => browse(d.parent); list.appendChild(up); }
|
|
495
|
+
for (const dir of d.dirs) {
|
|
496
|
+
const it = el("div", "fsitem");
|
|
497
|
+
it.appendChild(el("span", "", dir.name + "/"));
|
|
498
|
+
if (dir.project) it.appendChild(el("span", "proj", "✦ project"));
|
|
499
|
+
it.onclick = () => browse(dir.path);
|
|
500
|
+
it.ondblclick = () => openFolder(dir.path);
|
|
501
|
+
list.appendChild(it);
|
|
502
|
+
}
|
|
503
|
+
if (!d.dirs.length) list.appendChild(el("div", "sidehint", "no subfolders"));
|
|
504
|
+
}
|
|
505
|
+
function openFolder(p) {
|
|
506
|
+
post("/workspaces/add", { path: p, start: $("fsstart").checked }).then((r) => {
|
|
507
|
+
if (r.error) { alert(r.error); return; }
|
|
508
|
+
closeDialog();
|
|
509
|
+
if (r.id) { pendingSelect = r.id; show(r.id); }
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
$("openfolder").onclick = () => openDialog();
|
|
513
|
+
$("welcomeopen").onclick = () => openDialog();
|
|
514
|
+
$("fsclose").onclick = closeDialog;
|
|
515
|
+
$("modal").onclick = (e) => { if (e.target === $("modal")) closeDialog(); };
|
|
516
|
+
$("fsgo").onclick = () => browse($("fspath").value);
|
|
517
|
+
$("fspath").onkeydown = (e) => { if (e.key === "Enter") browse($("fspath").value); };
|
|
518
|
+
$("fsopen").onclick = () => openFolder(fsState.path);
|
|
519
|
+
|
|
520
|
+
// ---- right panel: browser + terminal tabs ---------------------------------
|
|
521
|
+
let panelWidth = Math.max(300, Number(ls.get("smol.panel.w")) || 520);
|
|
522
|
+
function panelKey(v) { return "smol.panel." + v.sid; }
|
|
523
|
+
function savePanel(v) {
|
|
524
|
+
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 })) }));
|
|
525
|
+
}
|
|
526
|
+
function loadPanelState(v) {
|
|
527
|
+
try {
|
|
528
|
+
const st = JSON.parse(ls.get(panelKey(v)) || "null");
|
|
529
|
+
if (!st) return;
|
|
530
|
+
for (const t of st.tabs || []) {
|
|
531
|
+
if (t.kind !== "browser") continue;
|
|
532
|
+
const tab = { kind: "browser", url: t.url || "", id: uid() };
|
|
533
|
+
buildBrowserTab(v, tab); v.tabs.push(tab);
|
|
534
|
+
}
|
|
535
|
+
v.panelOpen = !!st.open;
|
|
536
|
+
v.activeTab = st.active || (v.tabs[0] && v.tabs[0].id) || null;
|
|
537
|
+
} catch (e) {}
|
|
538
|
+
}
|
|
539
|
+
function curTab(v) { return v.tabs.find((t) => t.id === v.activeTab) || v.tabs[0] || null; }
|
|
540
|
+
function renderPanel() {
|
|
541
|
+
const v = active;
|
|
542
|
+
const open = !!(v && v.panelOpen && v.tabs.length);
|
|
543
|
+
panelEl.hidden = !open;
|
|
544
|
+
const cur = open ? curTab(v) : null;
|
|
545
|
+
$("btnbrowser").classList.toggle("on", !!(cur && cur.kind === "browser"));
|
|
546
|
+
$("btnterm").classList.toggle("on", !!(cur && cur.kind === "term"));
|
|
547
|
+
for (const o of views.values()) o.panelEl.hidden = o !== v || !open;
|
|
548
|
+
if (!open) return;
|
|
549
|
+
// Never let the panel squeeze the chat below ~40% of a small window.
|
|
550
|
+
panelEl.style.width = Math.min(panelWidth, Math.max(300, window.innerWidth * 0.6)) + "px";
|
|
551
|
+
tabsEl.innerHTML = "";
|
|
552
|
+
for (const t of v.tabs) {
|
|
553
|
+
const b = el("div", "ptab" + (t === cur ? " on" : ""));
|
|
554
|
+
b.appendChild(el("span", "ico", t.kind === "browser" ? "◎" : ">_"));
|
|
555
|
+
b.appendChild(el("span", "lbl", t.kind === "browser" ? (t.url ? t.url.replace(/^https?:\/\//, "") : "new tab") : "terminal " + t.tid.replace(/^t/, "")));
|
|
556
|
+
const x = el("span", "x", "×"); x.title = "close tab";
|
|
557
|
+
x.onclick = (e) => { e.stopPropagation(); closeTab(v, t); };
|
|
558
|
+
b.appendChild(x);
|
|
559
|
+
b.onclick = () => { v.activeTab = t.id; savePanel(v); renderPanel(); if (t.kind === "term" && t.inp) t.inp.focus(); };
|
|
560
|
+
b.title = t.kind === "browser" ? (t.url || "new browser tab") : "terminal in " + shortPath(t.cwd);
|
|
561
|
+
tabsEl.appendChild(b);
|
|
562
|
+
}
|
|
563
|
+
tabsEl.appendChild(el("span", "grow"));
|
|
564
|
+
const nb = el("button", "iconbtn", "+◎"); nb.title = "new browser tab"; nb.onclick = () => openBrowserTab(v);
|
|
565
|
+
const nt = el("button", "iconbtn", "+>_"); nt.title = "new terminal"; nt.onclick = () => openTerminalTab(v);
|
|
566
|
+
const cl = el("button", "iconbtn", "»"); cl.title = "hide panel"; cl.onclick = () => { v.panelOpen = false; savePanel(v); renderPanel(); };
|
|
567
|
+
tabsEl.appendChild(nb); tabsEl.appendChild(nt); tabsEl.appendChild(cl);
|
|
568
|
+
for (const t of v.tabs) if (t.el) t.el.hidden = t !== cur;
|
|
569
|
+
}
|
|
570
|
+
function closeTab(v, t) {
|
|
571
|
+
const i = v.tabs.indexOf(t);
|
|
572
|
+
if (t.kind === "term") { post("/term/close", { sid: v.sid, tid: t.tid }); removeTermTab(v, t.tid); return; }
|
|
573
|
+
if (i >= 0) v.tabs.splice(i, 1);
|
|
574
|
+
if (t.el) t.el.remove();
|
|
575
|
+
if (v.activeTab === t.id) v.activeTab = (v.tabs[i] || v.tabs[i - 1] || {}).id || null;
|
|
576
|
+
savePanel(v); renderPanel();
|
|
577
|
+
}
|
|
578
|
+
function togglePanelKind(kind) {
|
|
579
|
+
const v = active;
|
|
580
|
+
if (!v) return;
|
|
581
|
+
const cur = curTab(v);
|
|
582
|
+
if (v.panelOpen && cur && cur.kind === kind) { v.panelOpen = false; savePanel(v); renderPanel(); return; }
|
|
583
|
+
const existing = v.tabs.filter((t) => t.kind === kind).pop();
|
|
584
|
+
if (existing) {
|
|
585
|
+
v.activeTab = existing.id; v.panelOpen = true; savePanel(v); renderPanel();
|
|
586
|
+
if (kind === "term" && existing.inp) existing.inp.focus();
|
|
587
|
+
} else if (kind === "browser") openBrowserTab(v);
|
|
588
|
+
else openTerminalTab(v);
|
|
589
|
+
}
|
|
590
|
+
$("btnbrowser").onclick = () => togglePanelKind("browser");
|
|
591
|
+
$("btnterm").onclick = () => togglePanelKind("term");
|
|
592
|
+
|
|
593
|
+
// browser tabs
|
|
594
|
+
function fillUrls(t, urls) {
|
|
595
|
+
if (!t.dl) return;
|
|
596
|
+
const cur = [...t.dl.options].map((o) => o.value).join("|");
|
|
597
|
+
if (cur === urls.join("|")) return;
|
|
598
|
+
t.dl.innerHTML = "";
|
|
599
|
+
for (const u of urls) { const o = document.createElement("option"); o.value = u; t.dl.appendChild(o); }
|
|
600
|
+
t.urlsEl.innerHTML = "";
|
|
601
|
+
if (urls.length && !t.url) {
|
|
602
|
+
t.urlsEl.appendChild(el("div", "hint", "dev servers the agent started:"));
|
|
603
|
+
for (const u of urls) { const b = el("button", "ghost", u); b.onclick = () => t.nav(u); t.urlsEl.appendChild(b); }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function openBrowserTab(v, url) {
|
|
607
|
+
const t = { kind: "browser", url: "", id: uid() };
|
|
608
|
+
buildBrowserTab(v, t);
|
|
609
|
+
v.tabs.push(t); v.activeTab = t.id; v.panelOpen = true;
|
|
610
|
+
const start = url || (v.state.urls && v.state.urls[0]) || "";
|
|
611
|
+
if (start) t.nav(start); else { savePanel(v); renderPanel(); t.urlIn.focus(); }
|
|
612
|
+
}
|
|
613
|
+
function buildBrowserTab(v, t) {
|
|
614
|
+
const body = el("div", "tabbody browser"); body.hidden = true;
|
|
615
|
+
const bar = el("div", "bar");
|
|
616
|
+
const reload = el("button", "iconbtn", "↻"); reload.title = "reload";
|
|
617
|
+
const urlIn = document.createElement("input"); urlIn.placeholder = "http://localhost:5173"; urlIn.spellcheck = false;
|
|
618
|
+
const dlId = "dl_" + t.id; const dl = document.createElement("datalist"); dl.id = dlId; urlIn.setAttribute("list", dlId);
|
|
619
|
+
const go = el("button", "iconbtn", "→"); go.title = "go";
|
|
620
|
+
const ext = el("a", "iconbtn", "↗"); ext.title = "open in a new browser tab"; ext.target = "_blank"; ext.rel = "noopener";
|
|
621
|
+
bar.appendChild(reload); bar.appendChild(urlIn); bar.appendChild(dl); bar.appendChild(go); bar.appendChild(ext);
|
|
622
|
+
const empty = el("div", "empty");
|
|
623
|
+
empty.appendChild(el("div", "", "Enter a URL to preview it here."));
|
|
624
|
+
empty.appendChild(el("div", "hint", "Sites that refuse to be embedded still open with ↗."));
|
|
625
|
+
const urlsEl = el("div", "urls"); empty.appendChild(urlsEl);
|
|
626
|
+
const frame = document.createElement("iframe"); frame.hidden = true;
|
|
627
|
+
frame.setAttribute("allow", "clipboard-read; clipboard-write; fullscreen");
|
|
628
|
+
body.appendChild(bar); body.appendChild(empty); body.appendChild(frame);
|
|
629
|
+
v.panelEl.appendChild(body);
|
|
630
|
+
t.el = body; t.frame = frame; t.urlIn = urlIn; t.dl = dl; t.urlsEl = urlsEl;
|
|
631
|
+
t.nav = (u) => {
|
|
632
|
+
u = (u || "").trim();
|
|
633
|
+
if (!u) return;
|
|
634
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(u)) u = "http://" + u;
|
|
635
|
+
t.url = u; urlIn.value = u; ext.href = u;
|
|
636
|
+
frame.src = u; frame.hidden = false; empty.hidden = true;
|
|
637
|
+
savePanel(v); renderPanel();
|
|
638
|
+
};
|
|
639
|
+
go.onclick = () => t.nav(urlIn.value);
|
|
640
|
+
urlIn.onkeydown = (e) => { if (e.key === "Enter") t.nav(urlIn.value); };
|
|
641
|
+
reload.onclick = () => { if (t.url) frame.src = t.url; };
|
|
642
|
+
fillUrls(t, v.state.urls || []);
|
|
643
|
+
if (t.url) t.nav(t.url);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// terminal tabs
|
|
647
|
+
function ensureTermTab(v, tid, cwd) {
|
|
648
|
+
let t = v.terms.get(tid);
|
|
649
|
+
if (t) { if (cwd) setPrompt(t, cwd); return t; }
|
|
650
|
+
t = { kind: "term", tid, id: "t:" + tid, cwd: cwd || "", history: [], hi: 0, lines: 0, cur: null };
|
|
651
|
+
const body = el("div", "tabbody term"); body.hidden = true;
|
|
652
|
+
const out = el("pre", "out");
|
|
653
|
+
const row = el("div", "trow");
|
|
654
|
+
const prompt = el("span", "prompt");
|
|
655
|
+
const inp = document.createElement("input");
|
|
656
|
+
inp.placeholder = "command… enter runs · ctrl+c interrupts · ctrl+l clears"; inp.spellcheck = false; inp.autocomplete = "off";
|
|
657
|
+
inp.onkeydown = (e) => {
|
|
658
|
+
if (e.key === "Enter") {
|
|
659
|
+
const text = inp.value;
|
|
660
|
+
if (!text.trim()) return;
|
|
661
|
+
inp.value = "";
|
|
662
|
+
if (t.history[t.history.length - 1] !== text) t.history.push(text);
|
|
663
|
+
t.hi = t.history.length;
|
|
664
|
+
post("/term/input", { sid: v.sid, tid, text });
|
|
665
|
+
} else if (e.key === "c" && e.ctrlKey && !String(window.getSelection())) { e.preventDefault(); post("/term/interrupt", { sid: v.sid, tid }); }
|
|
666
|
+
else if (e.key === "l" && e.ctrlKey) { e.preventDefault(); out.innerHTML = ""; t.cur = null; t.lines = 0; }
|
|
667
|
+
else if (e.key === "ArrowUp") { if (t.hi > 0) { t.hi--; inp.value = t.history[t.hi]; } e.preventDefault(); }
|
|
668
|
+
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(); }
|
|
669
|
+
};
|
|
670
|
+
out.onclick = () => { if (!String(window.getSelection())) inp.focus(); };
|
|
671
|
+
row.appendChild(prompt); row.appendChild(inp);
|
|
672
|
+
body.appendChild(out); body.appendChild(row);
|
|
673
|
+
v.panelEl.appendChild(body);
|
|
674
|
+
t.el = body; t.out = out; t.inp = inp; t.promptEl = prompt;
|
|
675
|
+
setPrompt(t, cwd || "");
|
|
676
|
+
v.terms.set(tid, t); v.tabs.push(t);
|
|
677
|
+
if (!v.activeTab) v.activeTab = t.id;
|
|
678
|
+
if (v === active) renderPanel();
|
|
679
|
+
return t;
|
|
680
|
+
}
|
|
681
|
+
function setPrompt(t, cwd) { t.cwd = cwd; t.promptEl.textContent = (shortPath(cwd) || "…") + " ❯"; t.promptEl.title = cwd; }
|
|
682
|
+
function termWrite(v, tid, text) {
|
|
683
|
+
const t = v.terms.get(tid) || ensureTermTab(v, tid, "");
|
|
684
|
+
const out = t.out;
|
|
685
|
+
const nearBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 60;
|
|
686
|
+
const parts = text.split("\n");
|
|
687
|
+
for (let i = 0; i < parts.length; i++) {
|
|
688
|
+
let seg = parts[i];
|
|
689
|
+
if (i > 0) t.cur = null;
|
|
690
|
+
if (!t.cur) { t.cur = el("div", "l"); out.appendChild(t.cur); t.lines++; }
|
|
691
|
+
const cr = seg.lastIndexOf("\r");
|
|
692
|
+
if (cr >= 0) { t.cur.innerHTML = ""; seg = seg.slice(cr + 1); }
|
|
693
|
+
if (seg) t.cur.appendChild(ansiToFrag(seg));
|
|
694
|
+
}
|
|
695
|
+
while (t.lines > 4000 && out.firstChild) { out.removeChild(out.firstChild); t.lines--; }
|
|
696
|
+
if (nearBottom) out.scrollTop = out.scrollHeight;
|
|
697
|
+
}
|
|
698
|
+
function termDone(v, tid, code, cwd) {
|
|
699
|
+
const t = v.terms.get(tid);
|
|
700
|
+
if (!t) return;
|
|
701
|
+
if (cwd) setPrompt(t, cwd);
|
|
702
|
+
if (t.cur && t.cur.textContent) termWrite(v, tid, "\n");
|
|
703
|
+
if (code) termWrite(v, tid, "\x1b[2m[exit " + code + "]\x1b[0m\n");
|
|
704
|
+
}
|
|
705
|
+
function removeTermTab(v, tid) {
|
|
706
|
+
const t = v.terms.get(tid);
|
|
707
|
+
if (!t) return;
|
|
708
|
+
v.terms.delete(tid);
|
|
709
|
+
const i = v.tabs.indexOf(t);
|
|
710
|
+
if (i >= 0) v.tabs.splice(i, 1);
|
|
711
|
+
if (t.el) t.el.remove();
|
|
712
|
+
if (v.activeTab === t.id) v.activeTab = (v.tabs[i] || v.tabs[i - 1] || {}).id || null;
|
|
713
|
+
savePanel(v);
|
|
714
|
+
if (v === active) renderPanel();
|
|
715
|
+
}
|
|
716
|
+
function reconcileTerms(v) {
|
|
717
|
+
const info = sessInfo.get(v.sid);
|
|
718
|
+
const alive = new Set(info && info.live ? info.terminals.map((x) => x.tid) : []);
|
|
719
|
+
for (const tid of [...v.terms.keys()]) if (!alive.has(tid)) removeTermTab(v, tid);
|
|
720
|
+
if (info && info.live) for (const x of info.terminals) ensureTermTab(v, x.tid, x.cwd);
|
|
721
|
+
}
|
|
722
|
+
function openTerminalTab(v) {
|
|
723
|
+
post("/term/open", { sid: v.sid }).then((r) => {
|
|
724
|
+
if (!r.tid) { if (r.error) alert(r.error); return; }
|
|
725
|
+
const t = ensureTermTab(v, r.tid, r.cwd);
|
|
726
|
+
v.activeTab = t.id; v.panelOpen = true; savePanel(v); renderPanel();
|
|
727
|
+
t.inp.focus();
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// resize grip
|
|
732
|
+
$("panelgrip").onmousedown = (e) => {
|
|
733
|
+
e.preventDefault();
|
|
734
|
+
document.body.classList.add("dragging");
|
|
735
|
+
const move = (ev) => { panelWidth = Math.min(window.innerWidth * 0.8, Math.max(300, window.innerWidth - ev.clientX)); panelEl.style.width = panelWidth + "px"; };
|
|
736
|
+
const up = () => { document.body.classList.remove("dragging"); document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); ls.set("smol.panel.w", String(Math.round(panelWidth))); };
|
|
737
|
+
document.addEventListener("mousemove", move);
|
|
738
|
+
document.addEventListener("mouseup", up);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// ---- sidebar collapse -----------------------------------------------------
|
|
742
|
+
function setSide(collapsed) {
|
|
743
|
+
sideEl.classList.toggle("collapsed", collapsed);
|
|
744
|
+
$("sidetoggle").hidden = !collapsed;
|
|
745
|
+
ls.set("smol.side", collapsed ? "1" : "0");
|
|
746
|
+
}
|
|
747
|
+
function narrow() { return window.matchMedia("(max-width: 1000px)").matches; }
|
|
748
|
+
$("sidecollapse").onclick = () => setSide(true);
|
|
749
|
+
$("sidetoggle").onclick = () => setSide(false);
|
|
750
|
+
setSide(ls.get("smol.side") === "1" || (narrow() && !!location.hash));
|
|
751
|
+
|
|
752
|
+
// ---- input + slash menu ---------------------------------------------------
|
|
753
|
+
let menuIdx = 0;
|
|
754
|
+
function menuItems() {
|
|
755
|
+
const v = input.value;
|
|
756
|
+
if (!active || !v.startsWith("/") || v.includes(" ") || v.includes("\n")) return [];
|
|
757
|
+
return (active.state.commands || []).filter((c) => c.name.startsWith(v.slice(1)));
|
|
758
|
+
}
|
|
759
|
+
function renderMenu() {
|
|
760
|
+
const items = menuItems();
|
|
761
|
+
menu.style.display = items.length ? "block" : "none";
|
|
762
|
+
menu.innerHTML = "";
|
|
763
|
+
if (menuIdx >= items.length) menuIdx = 0;
|
|
764
|
+
items.forEach((c, i) => {
|
|
765
|
+
const d = el("div", "item" + (i === menuIdx ? " sel" : ""));
|
|
766
|
+
d.appendChild(el("span", "nm", "/" + c.name)); d.appendChild(el("span", "ds", c.desc));
|
|
767
|
+
d.onclick = () => { input.value = "/" + c.name; submit(); };
|
|
768
|
+
menu.appendChild(d);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function submit() {
|
|
772
|
+
if (!active) return;
|
|
773
|
+
let v = input.value;
|
|
774
|
+
const items = menuItems();
|
|
775
|
+
if (items.length) v = "/" + items[menuIdx].name;
|
|
776
|
+
v = v.trim();
|
|
777
|
+
if (!v) return;
|
|
778
|
+
input.value = ""; active.draft = ""; renderMenu(); autoGrow();
|
|
779
|
+
stickBottom = true;
|
|
780
|
+
post("/msg", { sid: active.sid, text: v });
|
|
781
|
+
}
|
|
782
|
+
function autoGrow() { input.rows = Math.min(6, Math.max(1, input.value.split("\n").length)); }
|
|
783
|
+
input.addEventListener("input", () => { menuIdx = 0; renderMenu(); autoGrow(); });
|
|
784
|
+
input.addEventListener("keydown", (e) => {
|
|
785
|
+
const items = menuItems();
|
|
786
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
|
|
787
|
+
else if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); if (active) post("/cycle", { sid: active.sid }); }
|
|
788
|
+
else if (e.key === "Tab" && items.length) { e.preventDefault(); input.value = "/" + items[menuIdx].name + " "; renderMenu(); }
|
|
789
|
+
else if (e.key === "ArrowUp" && items.length) { e.preventDefault(); menuIdx = (menuIdx - 1 + items.length) % items.length; renderMenu(); }
|
|
790
|
+
else if (e.key === "ArrowDown" && items.length) { e.preventDefault(); menuIdx = (menuIdx + 1) % items.length; renderMenu(); }
|
|
791
|
+
else if (e.key === "Escape") { if (input.value) { input.value = ""; renderMenu(); autoGrow(); } else if (active) post("/cancel", { sid: active.sid }); }
|
|
792
|
+
});
|
|
793
|
+
actionBtn.onclick = () => { if (!active) return; if (active.busyLabel) post("/cancel", { sid: active.sid }); else submit(); };
|
|
794
|
+
|
|
795
|
+
// ---- global keys ----------------------------------------------------------
|
|
796
|
+
document.addEventListener("keydown", (e) => {
|
|
797
|
+
if (e.key === "Escape" && document.activeElement !== input) {
|
|
798
|
+
if (!$("modal").hidden) closeDialog();
|
|
799
|
+
else if (active && !(document.activeElement && document.activeElement.closest && document.activeElement.closest(".tabbody.term"))) post("/cancel", { sid: active.sid });
|
|
800
|
+
} else if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key.toLowerCase() === "b") { e.preventDefault(); setSide(!sideEl.classList.contains("collapsed")); }
|
|
801
|
+
else if (e.ctrlKey && e.key === "\`") { e.preventDefault(); togglePanelKind("term"); }
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// ---- connect --------------------------------------------------------------
|
|
805
|
+
const es = new EventSource("/events?k=" + k);
|
|
806
|
+
es.onopen = () => {
|
|
807
|
+
// Everything is replayed on (re)connect: start each view from a clean slate.
|
|
808
|
+
for (const v of views.values()) {
|
|
809
|
+
v.logEl.innerHTML = ""; v.curText = null; v.curThought = null; v.asks.clear();
|
|
810
|
+
for (const t of v.terms.values()) { t.out.innerHTML = ""; t.cur = null; t.lines = 0; }
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
es.onmessage = (e) => handle(JSON.parse(e.data));
|
|
814
|
+
es.onerror = () => { $("status").textContent = "reconnecting…"; };
|
|
815
|
+
`;
|