@chalky/chalky 0.1.3 → 0.1.4

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.
@@ -13,6 +13,8 @@
13
13
  // are written down as words, between the steps.
14
14
  // Enter in the message line (also with the marker off) sends the steps, the drawing still on the page and
15
15
  // the words typed there, together: doodle_send, to the agent picked in the chat.
16
+ // boards going to another board (boards/) is like a click: the drawing so far becomes a step, then "went to
17
+ // board N". Each step says the board it was drawn on.
16
18
  // Things with a drag of their own keep it: a surface's label (moves it), its resize corner, lists to pick from,
17
19
  // sliders, draggable things, frames; text boxes don't (a drag that starts on one draws). Ctrl+Z takes back
18
20
  // the last mark; Esc turns the tools off (ink stays).
@@ -204,6 +206,7 @@ export default async function (page) {
204
206
  const box = marks.length ? bounds(marks.flatMap((m) => m.points)) : { x: 0, y: 0, w: innerWidth, h: innerHeight };
205
207
  const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
206
208
  const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
209
+ board: Number(document.body.dataset.board) || 1, // the board shown (boards/)
207
210
  area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
208
211
  step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
209
212
  steps.push(step);
@@ -211,6 +214,13 @@ export default async function (page) {
211
214
  marks = [];
212
215
  show();
213
216
  }
217
+ // going to another board (boards/, just before it changes) is like a click: the drawing so far becomes a step
218
+ document.addEventListener("board", (e) => {
219
+ if (!marks.length && !steps.length && !first.length) return; // no doodling going on
220
+ if (marks.length) keep(frame());
221
+ (steps.length ? steps.at(-1).then : first).push(`went to board ${e.detail.to}`);
222
+ render();
223
+ });
214
224
  function clicked(t, surface) { // a click, as words
215
225
  const label = short(t.getAttribute("aria-label") || t.title || t.textContent || t.value || t.localName, 30);
216
226
  const row = t.closest("tr, li");
@@ -382,7 +392,7 @@ export default async function (page) {
382
392
  render();
383
393
  await Promise.all(sent.map((s) => s.ready));
384
394
  page.send({ type: "doodle_send", to: document.getElementById("to")?.value, text, first: before,
385
- steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then })) });
395
+ steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then, board: s.board })) });
386
396
  allOff();
387
397
  }, true);
388
398
  }
@@ -30,7 +30,7 @@ function message(msg: any) {
30
30
  steps.forEach((st, i) => {
31
31
  const has = shots.includes(st.shot), a = st.area;
32
32
  if (has) n++;
33
- lines.push(`Step ${i + 1}` + (has ? ` (picture ${n})` : " (no picture)")
33
+ lines.push(`Step ${i + 1}` + (st.board ? ` on board ${st.board}` : "") + (has ? ` (picture ${n})` : " (no picture)")
34
34
  + (has && a ? `, showing x=${a.x} y=${a.y} w=${a.width} h=${a.height} of the page` : "")
35
35
  + (st.drawn ? "" : ": nothing drawn, how the screen looked after those clicks"));
36
36
  for (const w of st.words ?? []) lines.push(` words: "${w}"`);
@@ -64,8 +64,10 @@ export default async function (page) {
64
64
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOn(false); });
65
65
  send.onclick = () => {
66
66
  for (const flush of Object.values(timers)) { clearTimeout(flush.t); flush(); } // text still waiting on the typing pause goes first
67
- page.send({ type: "frames_send", to: document.getElementById("to")?.value });
67
+ page.send({ type: "frames_send", to: document.getElementById("to")?.value, board: board() });
68
68
  };
69
+ const board = () => Number(document.body.dataset.board) || 1; // the board shown (boards/): frames are drawn and sent there
70
+ document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
69
71
 
70
72
  const inMain = (x, y) => { // window px -> main-space px (as point.js)
71
73
  const m = area.getBoundingClientRect();
@@ -94,7 +96,7 @@ export default async function (page) {
94
96
  const f = { x: parseInt(ghost.style.left), y: parseInt(ghost.style.top), width: parseInt(ghost.style.width), height: parseInt(ghost.style.height) };
95
97
  ghost.remove();
96
98
  start = ghost = null;
97
- if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f }); } // a click or a slip is not a frame
99
+ if (f.width >= 40 && f.height >= 30) { focusNew = true; page.send({ type: "frame_add", ...f, board: board() }); } // a click or a slip is not a frame
98
100
  });
99
101
 
100
102
  const boxes = {}; // id -> its box on the page
@@ -103,7 +105,7 @@ export default async function (page) {
103
105
  document.addEventListener("erase", (e) => { // the doodle eraser at {x, y}, main px: unsent frames under it go
104
106
  const p = e.detail;
105
107
  for (const [id, f] of Object.entries(shown)) {
106
- if (f.sent || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
108
+ if (f.sent || (f.board ?? 1) !== board() || p.x < f.x || p.x > f.x + f.width || p.y < f.y || p.y > f.y + f.height) continue;
107
109
  delete shown[id]; // once, though the eraser keeps moving over it
108
110
  page.send({ type: "frame_remove", id });
109
111
  }
@@ -134,13 +136,14 @@ export default async function (page) {
134
136
  newest = box;
135
137
  }
136
138
  put(box, f);
139
+ box.dataset.board = f.board ?? 1; // boards/ shows it on its board
137
140
  box.classList.toggle("sent", f.sent);
138
141
  const text = box.querySelector("textarea");
139
142
  text.readOnly = f.sent;
140
143
  if (document.activeElement !== text) text.value = f.text;
141
144
  }
142
145
  if (focusNew && newest) { focusNew = false; newest.querySelector("textarea").focus(); }
143
- ready = Object.values(frames).filter((f) => !f.sent && f.text.trim()).length;
146
+ ready = Object.values(frames).filter((f) => !f.sent && f.text.trim() && (f.board ?? 1) === board()).length;
144
147
  showReady();
145
148
  send.title = `Implement: send ${ready} frame${ready > 1 ? "s" : ""} to the agent to build, together`;
146
149
  }
@@ -2,13 +2,14 @@
2
2
  // frames.js). browser.js adds a tool to the palette: drag boxes on the main space, type in each what goes there,
3
3
  // then press Implement. All the frames go together, as one message, to the agent picked in the chat (else agent1),
4
4
  // so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
5
- // A frame is {x, y, width, height, text, sent, to?} (main-space px, where ui_render's place puts a surface), with an
5
+ // A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where ui_render's place puts a surface;
6
+ // board: the one it was drawn on, boards/; Implement sends the frames of the board shown), with an
6
7
  // id (frame-1, frame-2, ...: never reused). An agent fills it with ui_render(surface="frame-1", place=<its place>);
7
8
  // when that surface appears (a ui watcher, host.api.ui.watch), the frame is done and its dashed box goes. The user
8
9
  // can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
9
10
  // GET /frames {id: frame}
10
- // page messages frame_add {x, y, width, height}, frame_place {id, x, y, width, height}, frame_text {id, text},
11
- // frame_remove {id}, frames_send {to}
11
+ // page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
12
+ // frame_remove {id}, frames_send {to, board}
12
13
  // event "frames" {frames: {id: frame}} to every open page, when they changed
13
14
  // Saved in state/frames.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
14
15
  import fs from "node:fs";
@@ -40,6 +41,13 @@ export default function (pi: ExtensionAPI) {
40
41
  host.api.ui?.watch("frames", (sid: string) => {
41
42
  if (frames[sid] && host.shared.get("ui")?.surfaces[sid]) { delete frames[sid]; changed(); }
42
43
  });
44
+ host.api.boards?.onRemove("frames", (n: number) => { // a removed board (boards/) takes its frames with it
45
+ const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
46
+ const gone = ids.map((id) => ({ owner: frames[id].sent ? frames[id].to : undefined, what: `frame ${id}` }));
47
+ for (const id of ids) delete frames[id];
48
+ if (ids.length) changed();
49
+ return gone;
50
+ });
43
51
  });
44
52
 
45
53
  host.routes.set("/frames", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(frames)));
@@ -47,7 +55,7 @@ export default function (pi: ExtensionAPI) {
47
55
  host.pageHandlers.set("frames", (msg: any) => {
48
56
  const f = frames[msg.id], swarm = host.api.swarm;
49
57
  if (msg.type === "frame_add") {
50
- frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false };
58
+ frames[`frame-${++saved.last}`] = { ...box(msg), text: "", sent: false, board: Number(msg.board) || 1 };
51
59
  changed();
52
60
  } else if (msg.type === "frame_place" && f && !f.sent) {
53
61
  Object.assign(f, box(msg, f));
@@ -61,13 +69,14 @@ export default function (pi: ExtensionAPI) {
61
69
  if (f.sent) swarm?.send("user", f.to, `[frames on the UI] the user removed ${msg.id} ("${f.text}"): don't fill it.`, true);
62
70
  } else if (msg.type === "frames_send" && swarm) {
63
71
  const to = msg.to in swarm.status() ? msg.to : "agent1";
64
- const ready = Object.entries(frames).filter(([, f]: any) => !f.sent && f.text.trim());
72
+ const board = Number(msg.board) || 1; // the board shown: its frames only (the user sees no others)
73
+ const ready = Object.entries(frames).filter(([, f]: any) => !f.sent && f.text.trim() && (f.board ?? 1) === board);
65
74
  if (!ready.length) return;
66
75
  for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
67
76
  changed();
68
- const lines = [`[frames on the UI] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} to fill:`,
77
+ const lines = [`[frames on the UI] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
69
78
  ...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
70
- " Fill each with ui_render(surface=<its id>, place=<its place>, ...); its dashed box goes when it appears."];
79
+ ` Fill each with ui_render(surface=<its id>, place=<its place>, board=${board}, ...); its dashed box goes when it appears.`];
71
80
  const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
72
81
  if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
73
82
  swarm.send("user", to, lines.join("\n"));
@@ -0,0 +1,363 @@
1
+ // Browser half of the login extension: /login and /logout open a dialog in the middle of the page, as /mcp does
2
+ // (a click on the backdrop, Esc or ✕ closes it; closing it during a sign-in cancels the sign-in).
3
+ // list Sign in (pi's providers) and custom endpoints, each "connected" / "sign in again" (GET /login)
4
+ // sign-in what pi asks for, as it asks (event "login"): a link to open, a code to type, a choice, a box to paste
5
+ // into; ✕ cancels
6
+ // custom name, API type, base URL, key (optional), model names as chips (Enter or "," after each; a paste is
7
+ // split); "Ask the endpoint" lists its models to pick from
8
+ // logout the saved logins, each with Log out / Remove
9
+ // model (/model, and after connecting) the usable models, grouped by provider; "Use for" all agents or one agent
10
+ // (an agent's own choice wins; "Same as all agents" drops it). A tick shows which agents use a model now
11
+ // The server does the work (index.ts); keys typed here go to it once and never come back. CHALKY_LOGIN=off: no commands.
12
+ export default function (page) {
13
+ const APIS = { "openai-completions": "OpenAI", "openai-responses": "OpenAI Responses", "anthropic-messages": "Anthropic",
14
+ "google-generative-ai": "Google" }; // the API the endpoint speaks
15
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
16
+
17
+ const style = document.createElement("style");
18
+ style.textContent = `
19
+ #login { width: min(560px, calc(100vw - 32px)); max-height: 75vh; padding: 0; box-sizing: border-box; color: var(--text);
20
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
21
+ border: 1px solid var(--edge); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; font-size: 13px; }
22
+ #login[open] { display: flex; flex-direction: column; }
23
+ #login::backdrop { background: rgba(0, 0, 0, .08); }
24
+ #login:focus { outline: none; }
25
+ #login .body > .btns:last-child { position: sticky; bottom: -16px; margin: 0 -14px -16px; padding: 10px 14px;
26
+ background: var(--glass-strong); border-top: 1px solid var(--line); } /* Use / Save stay in view */
27
+ #login .head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); font-weight: 600; }
28
+ #login .head button { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; font-weight: 400; cursor: pointer; }
29
+ #login .head .x { margin-left: auto; color: var(--muted); font-size: 15px; }
30
+ #login .body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 16px; display: grid; gap: 12px; align-content: start;
31
+ grid-auto-rows: max-content; } /* rows keep their height: the body scrolls, not each list */
32
+ #login .group { font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 4px 0 -6px; }
33
+ #login .rows { display: grid; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
34
+ #login .row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; cursor: pointer; }
35
+ #login .row + .row { border-top: 1px solid var(--line); }
36
+ #login .row:hover { background: var(--hover); }
37
+ #login .row .how { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; }
38
+ #login .dot { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; flex: none;
39
+ background: var(--hover); color: var(--soft); font-weight: 600; font-size: 11px; }
40
+ #login .chip { font-size: 10.5px; padding: 1px 7px; border-radius: 99px; white-space: nowrap; margin-left: auto; }
41
+ #login .chip.ok { background: rgba(52, 199, 89, 0.14); color: #1f8a3e; }
42
+ #login .chip.expired { background: rgba(255, 159, 10, 0.16); color: #b25e00; }
43
+ #login label { display: grid; gap: 5px; font-size: 12px; color: var(--soft); }
44
+ #login input, #login select { font: inherit; color: var(--text); background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line);
45
+ border-radius: 9px; padding: 7px 10px; outline: none; min-width: 0; }
46
+ #login input:focus, #login select:focus, #login .tags:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.12); }
47
+ #login .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
48
+ #login .two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
49
+ #login .btns { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
50
+ #login .btn { border: 0; border-radius: 9px; padding: 7px 14px; font: 500 13px inherit; font-family: inherit; cursor: pointer;
51
+ background: var(--hover); color: var(--text); text-decoration: none; }
52
+ #login .btn.primary { background: var(--accent); color: #fff; }
53
+ #login .btn.small { padding: 3px 10px; font-size: 12px; }
54
+ #login .btn:disabled { opacity: 0.5; cursor: default; }
55
+ #login .hint { font-size: 11.5px; color: var(--muted); }
56
+ #login .row .hint { white-space: nowrap; }
57
+ #login .error { color: var(--danger); font-size: 12.5px; }
58
+ #login .wait { display: flex; gap: 10px; align-items: center; color: var(--soft); }
59
+ #login .spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(0, 113, 227, 0.15); border-top-color: var(--accent);
60
+ animation: login-spin 1s linear infinite; flex: none; }
61
+ @keyframes login-spin { to { transform: rotate(360deg) } }
62
+ #login .code { font: 600 26px ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.12em; text-align: center; padding: 14px;
63
+ border-radius: 10px; background: rgba(255, 255, 255, 0.9); border: 1px dashed var(--line); user-select: all; }
64
+ #login details summary { cursor: pointer; color: var(--accent); font-size: 12.5px; }
65
+ #login details[open] summary { margin-bottom: 8px; }
66
+ #login .tags { display: flex; flex-wrap: wrap; gap: 6px; background: rgba(255, 255, 255, 0.9); border: 1px solid var(--line); border-radius: 9px; padding: 5px; }
67
+ #login .tags input { border: 0; box-shadow: none; padding: 3px 4px; flex: 1; min-width: 120px; background: none; }
68
+ #login .tag { font: 12px ui-monospace, "SF Mono", Menlo, monospace; background: rgba(0, 113, 227, 0.1); border-radius: 6px;
69
+ padding: 3px 4px 3px 8px; display: inline-flex; gap: 2px; align-items: center; }
70
+ #login .tag button { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0 4px; font-size: 11px; }
71
+ #login .found { max-height: 150px; overflow-y: auto; display: flex; flex-wrap: wrap; gap: 4px; }
72
+ #login .found button { border: 1px solid var(--line); background: none; border-radius: 6px; padding: 2px 7px; cursor: pointer;
73
+ font: 11.5px ui-monospace, "SF Mono", Menlo, monospace; color: var(--soft); }
74
+ #login .found button:hover { background: var(--hover); color: var(--text); }
75
+ #login .for { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; color: var(--soft); }
76
+ #login .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
77
+ #login .seg button { border: 0; background: none; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer; color: var(--text); }
78
+ #login .seg button.on { background: var(--accent); color: #fff; }
79
+ #login .radio { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid var(--muted); flex: none; box-sizing: border-box; }
80
+ #login .row.on .radio { border: 4.5px solid var(--accent); }
81
+ #login .model-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; overflow-wrap: anywhere; }`;
82
+ document.head.append(style);
83
+
84
+ const sheet = document.createElement("dialog");
85
+ sheet.id = "login";
86
+ document.body.append(sheet);
87
+ sheet.onclick = (e) => { if (e.target === sheet) sheet.close(); }; // a click on the backdrop closes it
88
+ sheet.onclose = () => { // ✕, Esc, the backdrop, Done
89
+ if (view === "signin") page.send({ type: "login_cancel" });
90
+ view = signing = form = null;
91
+ };
92
+
93
+ let view = null; // "list" | "signin" | "custom" | "logout" | "done"
94
+ let signing = null; // the provider being signed in to
95
+ let form = null; // the custom form's state: {id, editing, api, baseUrl, models: [], found: []}
96
+
97
+ function open(next) {
98
+ if (!sheet.open) sheet.showModal();
99
+ next();
100
+ }
101
+ const close = () => sheet.close();
102
+
103
+ function draw(title, body, back) {
104
+ sheet.innerHTML = `<div class="head">${back ? `<button class="back">‹ Back</button>` : ""}${esc(title)}<button class="x" title="Close">✕</button></div>
105
+ <div class="body">${body}</div>`;
106
+ sheet.querySelector(".x").onclick = close;
107
+ if (back) sheet.querySelector(".back").onclick = back;
108
+ return sheet.querySelector(".body");
109
+ }
110
+ const chip = (status) => status === "connected" ? `<span class="chip ok">connected</span>`
111
+ : status === "expired" ? `<span class="chip expired">sign in again</span>` : "";
112
+ const fetchList = () => fetch("/login").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/login: ${r.status}`)));
113
+
114
+ // ---- the list
115
+ async function showList() {
116
+ view = "list";
117
+ const body = draw("Connect a model", `<div class="wait"><span class="spin"></span>Checking your logins…</div>`);
118
+ let data;
119
+ try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
120
+ if (view !== "list") return;
121
+ body.innerHTML = `<input class="search" placeholder="Search…" aria-label="Search">
122
+ <div class="group">Sign in</div>
123
+ <div class="rows signin">${data.providers.map((p) => `<div class="row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
124
+ <span class="dot">${esc(p.name[0])}</span>${esc(p.name)}${p.subscription ? "" : `<span class="hint">not a subscription</span>`}
125
+ ${chip(p.status) || `<span class="how">sign in</span>`}</div>`).join("")}</div>
126
+ <div class="group">Custom endpoints</div>
127
+ <div class="rows custom">${data.custom.map((c) => `<div class="row" data-id="${esc(c.id)}">
128
+ <span class="dot">${esc(c.id[0].toUpperCase())}</span>${esc(c.id)} <span class="hint">${esc(c.models.length)} model${c.models.length === 1 ? "" : "s"}</span>
129
+ ${chip(c.status) || `<span class="how">edit</span>`}</div>`).join("")}
130
+ <div class="row new"><span class="dot">+</span>Custom endpoint<span class="how">URL · key · models</span></div></div>`;
131
+ const search = body.querySelector(".search");
132
+ search.oninput = () => {
133
+ const q = search.value.toLowerCase();
134
+ for (const r of body.querySelectorAll(".row:not(.new)")) r.style.display = `${r.dataset.id} ${r.dataset.name ?? ""}`.toLowerCase().includes(q) ? "" : "none";
135
+ };
136
+ search.focus();
137
+ for (const r of body.querySelectorAll(".signin .row")) r.onclick = () => startSignIn(r.dataset.id, r.dataset.name);
138
+ for (const r of body.querySelectorAll(".custom .row:not(.new)"))
139
+ r.onclick = () => showCustom(data.custom.find((c) => c.id === r.dataset.id));
140
+ body.querySelector(".row.new").onclick = () => showCustom();
141
+ }
142
+
143
+ // ---- sign in: the sheet shows what pi asks for, as it asks
144
+ function startSignIn(id, name) {
145
+ view = "signin";
146
+ signing = { id, name };
147
+ const body = draw(name, `<div class="link"></div><div class="wait"><span class="spin"></span><span class="msg">Starting…</span></div>
148
+ <div class="ask"></div><div class="error"></div>`, () => { page.send({ type: "login_cancel" }); showList(); });
149
+ body.dataset.provider = id;
150
+ page.send({ type: "login_start", provider: id });
151
+ }
152
+ function onSignIn(ev) {
153
+ const body = sheet.querySelector(".body");
154
+ if (view !== "signin" || !body) return;
155
+ const msg = body.querySelector(".msg"), link = body.querySelector(".link"), ask = body.querySelector(".ask");
156
+ if (ev.step === "notify") {
157
+ const e = ev.event;
158
+ if (e.type === "auth_url") {
159
+ link.innerHTML = `<div class="btns" style="justify-content:flex-start"><a class="btn primary" target="_blank" rel="noopener"
160
+ href="${esc(e.url)}">Open the ${esc(signing.name)} sign-in page ↗</a></div>`;
161
+ msg.textContent = "Waiting for you to finish in the other tab…";
162
+ } else if (e.type === "device_code") {
163
+ link.innerHTML = `<div class="hint" style="text-align:center">Go to <a target="_blank" rel="noopener" href="${esc(e.verificationUri)}">${esc(e.verificationUri)}</a> and enter</div>
164
+ <div class="code">${esc(e.userCode)}</div>
165
+ <div class="btns" style="justify-content:center"><button class="btn copy">Copy code</button>
166
+ <a class="btn primary" target="_blank" rel="noopener" href="${esc(e.verificationUri)}">Open ↗</a></div>`;
167
+ link.querySelector(".copy").onclick = () => navigator.clipboard?.writeText(e.userCode);
168
+ msg.textContent = "Waiting…";
169
+ } else msg.textContent = e.message; // progress, info
170
+ }
171
+ if (ev.step === "prompt") {
172
+ const p = ev.prompt;
173
+ if (p.type === "select") {
174
+ ask.innerHTML = `<div class="hint">${esc(p.message)}</div><div class="rows">${p.options.map((o) =>
175
+ `<div class="row" data-id="${esc(o.id)}">${esc(o.label)}</div>`).join("")}</div>`;
176
+ for (const r of ask.querySelectorAll(".row")) r.onclick = () => { ask.innerHTML = ""; page.send({ type: "login_answer", text: r.dataset.id }); };
177
+ msg.textContent = "Choose one:";
178
+ return;
179
+ }
180
+ // text, secret, manual_code: a box to type or paste into. manual_code goes with a link already open: tucked away
181
+ const field = `<label>${esc(p.message)}<input class="mono" type="${p.type === "secret" ? "password" : "text"}"
182
+ placeholder="${esc(p.placeholder ?? "")}"></label><div class="btns"><button class="btn primary">Continue</button></div>`;
183
+ ask.innerHTML = p.type === "manual_code" ? `<details><summary>The page after signing in didn't load?</summary>${field}</details>` : field;
184
+ const input = ask.querySelector("input"), go = () => { page.send({ type: "login_answer", text: input.value }); ask.innerHTML = ""; msg.textContent = "Checking…"; };
185
+ ask.querySelector(".btn").onclick = go;
186
+ input.onkeydown = (e) => { if (e.key === "Enter") go(); };
187
+ if (p.type !== "manual_code") input.focus();
188
+ }
189
+ if (ev.step === "prompt_done") ask.innerHTML = "";
190
+ }
191
+
192
+ // ---- custom endpoint
193
+ function showCustom(c) {
194
+ view = "custom";
195
+ form = { editing: !!c, found: [], models: [...(c?.models ?? [])] };
196
+ const body = draw(c ? c.id : "Custom endpoint", `
197
+ <div class="two"><label>Name<input class="id" value="${esc(c?.id ?? "")}" placeholder="openrouter" ${c ? "disabled" : ""}></label>
198
+ <label>Speaks<select class="api">${Object.entries(APIS).map(([v, l]) => `<option value="${v}" ${v === (c?.api ?? "openai-completions") ? "selected" : ""}>${l}</option>`).join("")}</select></label></div>
199
+ <label>Base URL<input class="url mono" value="${esc(c?.baseUrl ?? "")}" placeholder="https://openrouter.ai/api/v1"></label>
200
+ <label><span>API key <span class="hint">(optional${c ? "; leave empty to keep the saved one" : ""})</span></span><input class="key mono" type="password" autocomplete="off"></label>
201
+ <label><span>Models <span class="hint">(Enter or "," after each; paste several at once)</span></span><div class="tags"><input class="add mono" placeholder="model name"></div></label>
202
+ <div class="found"></div>
203
+ <div class="error"></div>
204
+ <div class="btns" style="justify-content:space-between"><button class="btn ask">Ask the endpoint</button><button class="btn primary save">Save</button></div>`,
205
+ showList);
206
+ const tags = body.querySelector(".tags"), add = body.querySelector(".add"), found = body.querySelector(".found");
207
+ const drawTags = () => {
208
+ for (const t of tags.querySelectorAll(".tag")) t.remove();
209
+ for (const m of form.models) {
210
+ const t = document.createElement("span");
211
+ t.className = "tag";
212
+ t.innerHTML = `${esc(m)}<button title="Remove">✕</button>`;
213
+ t.querySelector("button").onclick = (e) => { e.preventDefault(); form.models = form.models.filter((x) => x !== m); drawTags(); drawFound(); };
214
+ tags.insertBefore(t, add);
215
+ }
216
+ };
217
+ const drawFound = () => { // the endpoint's own names, narrowed by what is being typed; click one to add it
218
+ const q = add.value.trim().toLowerCase();
219
+ const left = form.found.filter((m) => !form.models.includes(m) && m.toLowerCase().includes(q));
220
+ found.innerHTML = left.slice(0, 200).map((m) => `<button data-m="${esc(m)}">${esc(m)}</button>`).join("")
221
+ + (left.length > 200 ? `<span class="hint">${left.length - 200} more: type to narrow</span>` : "");
222
+ for (const b of found.querySelectorAll("button")) b.onclick = () => addNames([b.dataset.m]);
223
+ };
224
+ const addNames = (names) => {
225
+ for (const n of names.map((s) => s.trim()).filter(Boolean)) if (!form.models.includes(n)) form.models.push(n);
226
+ add.value = "";
227
+ drawTags(); drawFound();
228
+ };
229
+ add.onkeydown = (e) => {
230
+ if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addNames(add.value.split(",")); }
231
+ else if (e.key === "Backspace" && !add.value && form.models.length) { form.models.pop(); drawTags(); drawFound(); }
232
+ };
233
+ add.oninput = drawFound;
234
+ add.onpaste = (e) => { e.preventDefault(); addNames((e.clipboardData.getData("text") || "").split(/[\s,]+/)); };
235
+ tags.onclick = () => add.focus();
236
+ drawTags();
237
+ const value = (sel) => body.querySelector(sel).value.trim();
238
+ body.querySelector(".ask").onclick = () => {
239
+ body.querySelector(".error").textContent = "";
240
+ found.innerHTML = `<span class="hint">Asking…</span>`;
241
+ page.send({ type: "login_list_models", id: c?.id, api: value(".api"), baseUrl: value(".url"), key: value(".key") });
242
+ };
243
+ body.querySelector(".save").onclick = () => {
244
+ if (add.value.trim()) addNames(add.value.split(",")); // names typed but not yet added
245
+ body.querySelector(".error").textContent = "";
246
+ body.querySelector(".save").disabled = true;
247
+ page.send({ type: "login_custom", id: value(".id"), api: value(".api"), baseUrl: value(".url"), key: value(".key"), models: form.models });
248
+ };
249
+ (c ? add : body.querySelector(".id")).focus();
250
+ }
251
+
252
+ // ---- logout
253
+ async function showLogout() {
254
+ view = "logout";
255
+ const body = draw("Saved logins", `<div class="wait"><span class="spin"></span>Loading…</div>`);
256
+ let data;
257
+ try { data = await fetchList(); } catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
258
+ if (view !== "logout") return;
259
+ const rows = [...data.providers.filter((p) => p.status).map((p) => ({ id: p.id, name: p.name, how: p.status === "expired" ? "expired" : "signed in", act: "Log out" })),
260
+ ...data.custom.map((c) => ({ id: c.id, name: `${c.id} (custom)`, how: c.status ? "key" : "no key", act: "Remove" }))];
261
+ body.innerHTML = rows.length ? `<div class="rows">${rows.map((r) => `<div class="row" data-id="${esc(r.id)}"><span class="dot">${esc(r.name[0].toUpperCase())}</span>
262
+ ${esc(r.name)}<span class="how">${r.how}</span><button class="btn small">${r.act}</button></div>`).join("")}</div>
263
+ <div class="hint">Log out removes the login from this machine (app/auth.json). Remove also deletes the endpoint.</div>`
264
+ : `<div class="hint">No saved logins. Type /login to add one.</div>`;
265
+ for (const b of body.querySelectorAll(".row .btn")) b.onclick = () => {
266
+ b.disabled = true;
267
+ page.send({ type: "logout", provider: b.closest(".row").dataset.id });
268
+ };
269
+ }
270
+
271
+ // ---- choose the model
272
+ async function showModels(title = "Choose the model") {
273
+ view = "model";
274
+ const body = draw(title, `<div class="wait"><span class="spin"></span>Loading models…</div>`);
275
+ let data;
276
+ try { data = await fetch("/login/models").then((r) => r.ok ? r.json() : Promise.reject(new Error(`/login/models: ${r.status}`))); }
277
+ catch (e) { body.innerHTML = `<div class="error">${esc(e.message)}</div>`; return; }
278
+ if (view !== "model") return;
279
+ const key = (m) => m ? `${m.provider}/${m.id}` : "same";
280
+ const firstAgentModel = data.agents[0]?.model;
281
+ form = { scope: "all", pick: key(data.default ?? firstAgentModel) };
282
+ const label = (k) => k === "same" ? "the all-agents model" : k.slice(k.indexOf("/") + 1);
283
+ function render() {
284
+ const agent = data.agents.find((a) => a.name === form.scope);
285
+ const users = (k) => data.agents.filter((a) => key(a.model) === k).map((a) => a.name);
286
+ body.innerHTML = `<div class="for">Use for <span class="seg"><button data-for="all" class="${form.scope === "all" ? "on" : ""}">All agents</button>${data.agents.map((a) =>
287
+ `<button data-for="${esc(a.name)}" class="${form.scope === a.name ? "on" : ""}">${esc(a.name)}</button>`).join("")}</span></div>
288
+ <div class="hint">${agent ? `Only ${esc(agent.name)}; the others keep theirs.` : "Every agent that hasn't picked its own."}</div>
289
+ <input class="search" placeholder="Search models…" aria-label="Search models">
290
+ ${agent ? `<div class="rows"><div class="row ${form.pick === "same" ? "on" : ""}" data-k="same"><span class="radio"></span>Same as all agents
291
+ <span class="how">${esc(data.default ? data.default.id : "MODEL_BASE_URL")}</span></div></div>` : ""}
292
+ ${data.groups.map((g) => `<div class="group">${esc(g.name)}</div><div class="rows">${g.models.map((m) => {
293
+ const k = `${g.provider}/${m.id}`, who = users(k);
294
+ return `<div class="row ${form.pick === k ? "on" : ""}" data-k="${esc(k)}" data-q="${esc(`${g.name} ${m.id} ${m.name}`.toLowerCase())}"><span class="radio"></span>
295
+ <span class="model-id">${esc(m.id)}</span><span class="how">${who.map((n) => `${esc(n)} ✓`).join(" ")}${who.length && m.context ? " · " : ""}${m.context ? `${Math.round(m.context / 1000)}k` : ""}</span></div>`;
296
+ }).join("")}</div>`).join("") || `<div class="hint">No models yet: type /login to connect one.</div>`}
297
+ <div class="error"></div>
298
+ <div class="btns"><button class="btn primary use">Use ${esc(label(form.pick))}</button></div>`;
299
+ for (const b of body.querySelectorAll(".seg button")) b.onclick = () => {
300
+ form.scope = b.dataset.for;
301
+ const a = data.agents.find((x) => x.name === form.scope);
302
+ form.pick = a ? (a.own ? key(a.model) : "same") : key(data.default ?? firstAgentModel);
303
+ render();
304
+ };
305
+ for (const r of body.querySelectorAll(".row[data-k]")) r.onclick = () => {
306
+ form.pick = r.dataset.k;
307
+ for (const x of body.querySelectorAll(".row.on")) x.classList.remove("on");
308
+ r.classList.add("on");
309
+ body.querySelector(".use").textContent = `Use ${label(form.pick)}`;
310
+ };
311
+ const search = body.querySelector(".search");
312
+ search.oninput = () => {
313
+ const q = search.value.toLowerCase();
314
+ for (const r of body.querySelectorAll(".row[data-q]")) r.style.display = r.dataset.q.includes(q) ? "" : "none";
315
+ };
316
+ body.querySelector(".use").onclick = () => {
317
+ const [provider, ...rest] = form.pick === "same" ? [] : form.pick.split("/");
318
+ if (form.scope === "all" && !provider) return;
319
+ form.said = form.scope === "all" ? `all agents now use ${rest.join("/")}`
320
+ : provider ? `${form.scope} now uses ${rest.join("/")}` : `${form.scope} is back on the all-agents model`;
321
+ body.querySelector(".use").disabled = true;
322
+ page.send({ type: "model_set", for: form.scope, provider, id: rest.join("/") || undefined });
323
+ };
324
+ }
325
+ render();
326
+ }
327
+
328
+ // ---- what the server says
329
+ page.on("login", (ev) => {
330
+ if (!sheet.open) return;
331
+ const err = sheet.querySelector(".error");
332
+ if (ev.step === "error") {
333
+ if (err) err.textContent = ev.text;
334
+ sheet.querySelector(".save")?.removeAttribute("disabled");
335
+ const msg = sheet.querySelector(".msg");
336
+ if (view === "signin" && msg) { msg.textContent = "Didn't work:"; sheet.querySelector(".spin")?.remove(); }
337
+ if (view === "custom") sheet.querySelector(".found").innerHTML = "";
338
+ if (view === "model") { sheet.querySelector(".use")?.removeAttribute("disabled"); form.said = null; }
339
+ return;
340
+ }
341
+ if (ev.step === "done") {
342
+ page.note(`connected to ${ev.provider}`);
343
+ return showModels(`Connected to ${ev.provider} ✓`);
344
+ }
345
+ if (ev.step === "cancelled" && view === "signin") return showList();
346
+ if (ev.step === "models" && view === "custom") {
347
+ form.found = ev.models;
348
+ sheet.querySelector(".add").dispatchEvent(new Event("input"));
349
+ if (!ev.models.length) sheet.querySelector(".found").innerHTML = `<span class="hint">The endpoint listed no models: type their names.</span>`;
350
+ return;
351
+ }
352
+ if (ev.step === "changed" && view === "logout") return showLogout();
353
+ if (ev.step === "changed" && view === "model" && form?.said) { page.note(form.said); return close(); }
354
+ onSignIn(ev);
355
+ });
356
+
357
+ fetch("/login/on").then((r) => { // CHALKY_LOGIN=off: the server has no /login, and the page no commands
358
+ if (!r.ok) return;
359
+ page.command("login", () => open(showList), "Connect a model: sign in with a subscription, or a custom endpoint");
360
+ page.command("logout", () => open(showLogout), "Remove a saved login");
361
+ page.command("model", () => open(() => showModels()), "Choose the model, for all agents or one");
362
+ });
363
+ }