@chalky/chalky 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +8 -2
  2. package/agent/SYSTEM.md +2 -1
  3. package/agent/extensions/app/README.md +15 -11
  4. package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
  5. package/agent/extensions/board/browser.js +5 -9
  6. package/agent/extensions/card/index.ts +18 -18
  7. package/agent/extensions/file/browser.js +46 -7
  8. package/agent/extensions/file/index.ts +3 -2
  9. package/agent/extensions/frame/README.md +2 -2
  10. package/agent/extensions/frame/index.ts +16 -7
  11. package/agent/extensions/history/index.ts +3 -1
  12. package/agent/extensions/note/README.md +3 -1
  13. package/agent/extensions/note/browser.js +33 -7
  14. package/agent/extensions/note/guide.md +2 -1
  15. package/agent/extensions/note/index.ts +36 -13
  16. package/agent/extensions/page/browser.js +453 -0
  17. package/agent/extensions/page/index.ts +70 -1
  18. package/agent/extensions/page/page.html +47 -0
  19. package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
  20. package/agent/extensions/screen/browser.js +6 -19
  21. package/agent/extensions/version/browser.js +9 -0
  22. package/agent/extensions/version/index.ts +73 -21
  23. package/agent/skills/design/SKILL.md +1 -1
  24. package/agent/skills/show-me/SKILL.md +66 -0
  25. package/package.json +2 -1
  26. package/server.ts +169 -7
  27. package/node_modules/xlsx/CHANGELOG.md +0 -361
  28. package/node_modules/xlsx/LICENSE +0 -201
  29. package/node_modules/xlsx/README.md +0 -41
  30. package/node_modules/xlsx/bin/xlsx.njs +0 -310
  31. package/node_modules/xlsx/bower.json +0 -22
  32. package/node_modules/xlsx/dist/LICENSE +0 -201
  33. package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
  34. package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
  35. package/node_modules/xlsx/dist/cpexcel.js +0 -1506
  36. package/node_modules/xlsx/dist/shim.min.js +0 -2
  37. package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
  38. package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
  39. package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
  40. package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
  41. package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
  42. package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
  43. package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
  44. package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
  45. package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
  46. package/node_modules/xlsx/dist/zahl.d.ts +0 -4
  47. package/node_modules/xlsx/package.json +0 -183
  48. package/node_modules/xlsx/types/index.d.ts +0 -1065
  49. package/node_modules/xlsx/types/tsconfig.json +0 -15
  50. package/node_modules/xlsx/xlsx.js +0 -28103
  51. package/node_modules/xlsx/xlsx.mjs +0 -28228
  52. package/node_modules/xlsx/xlsxworker.js +0 -14
package/README.md CHANGED
@@ -21,6 +21,9 @@ MODEL_BASE_URL=https://your-endpoint/v1 MODEL_API_KEY=... MODEL_NAME=your-model
21
21
 
22
22
  Then open http://127.0.0.1:3000.
23
23
 
24
+ To update: `npm update -g @chalky/chalky`, then restart `chalky`. The new version is merged with what your agents
25
+ changed (see `agent/` below).
26
+
24
27
  | Variable | |
25
28
  |---|---|
26
29
  | `MODEL_BASE_URL` | the endpoint, up to `/v1` |
@@ -34,8 +37,11 @@ Then open http://127.0.0.1:3000.
34
37
 
35
38
  `~/.chalky` (or `CHALKY_HOME`) holds:
36
39
 
37
- - `agent/`: what the agents are: their prompt, settings and extensions (the page among them), copied from the
38
- package on the first run. The agents edit it. A newer version of the package doesn't overwrite it.
40
+ - `agent/`: what the agents are: their prompt, settings and extensions (the page among them). The agents edit it.
41
+ Each new version of the package is merged into it at the next start, as a version in the history: files only the
42
+ new version changed are updated, files only your agents changed stay theirs, and both changes go in when they
43
+ touch different lines. When both changed the same lines, yours stays, the new version waits next to it as
44
+ `<file>.new`, and agent1 merges the two. Undo an update like any change, in Versions.
39
45
  - `built/`: what the agents made for you: the cards on your boards, and the apps they wrote.
40
46
  - `data/`: what is kept while running: conversations, what you typed into cards and apps, notes, undo history.
41
47
  - `workspace/`: the agents' working folder; `workspace/drops/` holds your files.
package/agent/SYSTEM.md CHANGED
@@ -1,6 +1,7 @@
1
1
  You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (cards on
2
2
  boards, each showing blocks or an app) fills the screen, and the conversation is a side panel. The user says what they want; you put a
3
- working first version on screen; then they point at things on it and leave notes, and you change exactly that.
3
+ first version on screen that is already complete and polished; then they point at things on it and leave notes, and
4
+ you change exactly that. The user marks up details, not missing basics.
4
5
  - Build, don't describe. A first version they can point at beats questions you could answer by building.
5
6
  - Your final reply is the only text they read in full: one or two sentences on what changed.
6
7
  - The page around your work is the user's: change it only when asked. Build inside the main space.
@@ -1,5 +1,12 @@
1
1
  # Making an app (the app extension)
2
- Everything a usual app needs is here: for one, you don't need the card README or the design and browser-check skills.
2
+ Everything a usual app needs is here (you don't need the card README). The design skill goes deeper on charts,
3
+ diagrams, game art and one look across cards: load it when the app has any of those.
4
+
5
+ ## Before you write
6
+ The first version is already complete and polished: the user marks up details, not missing basics. So first list,
7
+ in a short message, the 3-5 things that would make this app impressive for this subject: what someone who knows the
8
+ subject would expect to find, and a picture (diagram, chart, animation) wherever one explains faster than text.
9
+ Then build all of them in the first version, not a smaller first cut.
3
10
 
4
11
  ## Write it, show it
5
12
  - Write {built}/apps/<name>/index.html (other files next to it), then card_show(card=<id>, app=<name>, about="...").
@@ -45,20 +52,17 @@ The page is light, glassy and quiet; match it unless the user asks for another l
45
52
  - Show just the thing: no frame or panel around it unless it needs one. Group with flex/grid and gap.
46
53
  - Open in a working state (its real data, or a marked example), never an empty shell; `tabular-nums` for digits.
47
54
  - Buttons say what happens ("Add habit"); short plain words; status colours always with a word or icon.
55
+ - Ground it in the subject: its real units, terms and conventions (a chess clock shows mm:ss; a kanban card has an
56
+ owner and a state). Real content, never lorem ipsum.
57
+ - Summary before detail; show state in form (a pill, a chip, a stripe) as well as numbers.
58
+ - Finish every state: empty, loading, error, done. Interactive things look interactive (hover, pressed, visible
59
+ keyboard focus); a small transition where something changes, never content hidden waiting for an animation.
48
60
  - Avoid the generic AI look: cream and terracotta, purple gradients, emoji as markers, the same shadowed card on
49
61
  everything. For a chart, a diagram, a game's art or a restyle, load the design skill.
50
62
 
51
63
  ## Check it works
52
- card_show covers errors and fit. Before telling the user what it does, try its main action and a reload with
53
- {agent}/skills/browser-check/scripts/check.mjs:
54
- ```
55
- node <that check.mjs> {page}/built/apps/<name>/index.html "eval:(()=>{const i=document.querySelector('#new'); i.value='Test-1';
56
- i.dispatchEvent(new Event('input',{bubbles:true})); i.form.requestSubmit(); return 'ok'})()" wait:800 \
57
- "eval:document.body.innerText.includes('Test-1')"
58
- ```
59
- Set values with eval (`type:` adds to what a field already holds). Run it again to see what a reload keeps.
60
- Your test writes the user's real data: note it first (cat {data}/app/<name>.json) and put it back afterwards
61
- (PUT, above). Say only what you saw working.
64
+ card_show covers errors and fit. Then, before telling the user what it does, use it on their page as they would:
65
+ load the test-your-app skill. Say only what you saw working.
62
66
 
63
67
  ## More
64
68
  - Calculations (totals, streaks, dates) belong in the page's code, never worked out by you and typed in.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: test-your-app
3
+ description: Test an app you built by using it on the user's page as they would - its main tasks start to finish, a look at it, a reload. Use after card_show, before you tell the user what the app does or that it works.
4
+ ---
5
+ # Testing an app you built
6
+
7
+ Load the interact-with-page skill first: this one only adds what testing needs.
8
+
9
+ 1. Save the app's data: `cat {data}/app/<name>.json > /tmp/<name>.saved.json` (no file: it has saved nothing yet).
10
+ The test writes the user's real data.
11
+ 2. List the user's main tasks, from the app's about and your plan: add, change, remove, filter, the one idea that
12
+ makes it worth having.
13
+ 3. Do each task start to finish through the interface, with the helpers, one or a few steps per page_run, each
14
+ ending with a read. Never call the app's own functions or set its state yourself: that tests your code, not what
15
+ the user gets. After each step: did what you expected appear? Any errors in the answer?
16
+ 4. Look once: screen_see, and check what reading can't: text cut off or overlapping, things outside their box,
17
+ colours off the palette, a chart that doesn't match its numbers, empty space. If the user doesn't share their
18
+ screen, take a browser-check screenshot of {page}/built/apps/<name>/index.html (--shot) and read it.
19
+ 5. Reload: `location.reload()` in one page_run, then a read in the next: what was kept?
20
+ 6. Fix what you found (edit the part), show it again, and redo the tasks that touched it.
21
+ 7. Put the data back: `curl -X PUT --data @/tmp/<name>.saved.json {page}/app/data/<name>` (nothing saved: `--data '{}'`).
22
+ 8. Tell the user what you did and what you saw ("added and ticked a habit, reloaded: kept; long names were cut off,
23
+ now they wrap"), not just "it works".
@@ -25,9 +25,6 @@ const CSS = `
25
25
  .boards-shelf .slot .x { display: none; position: absolute; top: 3px; right: 3px; width: 16px; height: 16px; border-radius: 50%;
26
26
  font-size: 9px; line-height: 16px; text-align: center; color: var(--danger); background: var(--glass-strong); box-shadow: var(--shadow); }
27
27
  .boards-shelf.open .slot:hover .x { display: block; }
28
- .boards-toast { position: fixed; left: 50%; top: 76px; transform: translateX(-50%); /* under the palette: clear of the shelf */ z-index: 27; display: flex; gap: 12px;
29
- align-items: center; padding: 7px 14px; border-radius: 10px; background: rgba(29, 29, 31, 0.88); color: #fff; font-size: 13px; }
30
- .boards-toast button { color: #9cc3f5; font-weight: 600; }
31
28
  .boards-shelf .dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(0, 0, 0, 0.22); transition: all 0.15s; }
32
29
  .boards-shelf .slot.on .dot { width: 11px; height: 11px; background: var(--text); }
33
30
  /* more boards than fit (fit(): clear of the chat panel): the thumbnails scroll inside .view, between ‹ and › */
@@ -173,8 +170,7 @@ export default async function (page) {
173
170
  // ✕ on a thumbnail: the board leaves the shelf with a few seconds of Undo, and only then goes from the server
174
171
  // (board_remove: its things go with it). Leaving the page in those seconds removes it at once.
175
172
  let pending = null; // {n, was: the board shown when ✕ was clicked, timer}
176
- const toast = Object.assign(document.createElement("div"), { className: "boards-toast", hidden: true });
177
- document.body.append(toast);
173
+ let toast = null; // the notice with Undo (pageApi.notice)
178
174
  function removeBoard(n) {
179
175
  if (pending) commit();
180
176
  const i = boards.indexOf(n);
@@ -182,20 +178,20 @@ export default async function (page) {
182
178
  peek.hidden = true;
183
179
  if (current === n) show(boards.filter((b) => b !== n)[Math.max(0, i - 1)]); // the one before it (else after)
184
180
  else render();
185
- toast.replaceChildren(`Board ${n} removed`, button("", "Undo", "Bring it back", undo));
186
- toast.hidden = false;
181
+ toast?.close();
182
+ toast = page.notice([`Board ${n} removed`, button("go", "Undo", "Bring it back", undo)], () => pending && commit()); // ✕: removed now
187
183
  }
188
184
  function commit() {
189
185
  clearTimeout(pending.timer);
190
186
  page.send({ type: "board_remove", board: pending.n });
191
187
  pending = null;
192
- toast.hidden = true;
188
+ toast?.close();
193
189
  }
194
190
  function undo() {
195
191
  clearTimeout(pending.timer);
196
192
  const { n, was } = pending;
197
193
  pending = null;
198
- toast.hidden = true;
194
+ toast?.close();
199
195
  if (was === n) show(n);
200
196
  else render();
201
197
  }
@@ -5,10 +5,10 @@
5
5
  // skill's headless browser and says what it found: errors, and whether the app fits its card. The cards are shared by every agent: one Cards object on
6
6
  // the host, made by whichever agent loads first.
7
7
  // One door: every change goes through change(): it saves, tells every open page, and calls every watcher with the
8
- // card, who did it and the words for it ("you moved s1"; none: part of a bigger change). restore() puts back a
9
- // saved state (not a change: no watchers). Watchers, registered from session_start:
8
+ // card, who did it ("user" or the agent) and the words for it, without who ("moved s1"; none: part of a bigger
9
+ // change). restore() puts back a saved state (not a change: no watchers). Watchers, registered from session_start:
10
10
  // host.api.card.watch(name, fn(card, by, what)) name: the extension's, so its reload replaces its own
11
- // (history: a step per change with words; frame: a frame is done when its card appears).
11
+ // (history: a step per change with words; frame: a frame is done when its card appears; version: a version's label).
12
12
  // built/card.json each card but its data (versioned) data/card.json each card's data (what users typed)
13
13
  // A card: {owner, blocks | app, data, place, board, about, pinned, options, made_from, instruction, draggable,
14
14
  // resizable, height (an app's, when nothing else sizes it), kinds (each block's about.version, for upgrades)}.
@@ -200,7 +200,7 @@ export default function (pi: ExtensionAPI) {
200
200
  const gone = ids.map((id) => ({ owner: cards.cards[id].owner, what: `card ${id}` }));
201
201
  ids.forEach((id, i) => {
202
202
  cards.copyIntoUsers(id); // cards elsewhere reading from it keep its values
203
- cards.change(id, null, "user", i === ids.length - 1 ? `you removed board ${n} (${ids.join(", ")})` : undefined);
203
+ cards.change(id, null, "user", i === ids.length - 1 ? `removed board ${n} (${ids.join(", ")})` : undefined);
204
204
  });
205
205
  return gone;
206
206
  }));
@@ -268,13 +268,13 @@ export default function (pi: ExtensionAPI) {
268
268
  ["instruction", a.instruction ?? old.instruction], ["options", options.length ? options : null]] as const) if (v) record[k] = v;
269
269
  // its board (board/): the one asked for, a new one, where it already is, or the one the user is on
270
270
  if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
271
- cards.change(a.card, record, agent, `${agent} showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
271
+ cards.change(a.card, record, agent, `showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
272
272
  const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
273
273
  if (options.length) return result(`card '${a.card}' shows ${options.length} options ${where}; the user flips through them and keeps one`);
274
274
  if (!main.app) return result(`card '${a.card}' is ${where}`);
275
275
  return result(`card '${a.card}' is ${where}, showing ${host.built}/${main.app} (at http://127.0.0.1:${host.port}/built/${main.app}); `
276
- + await checkOnBoard(a.card) + ". Errors and fit are all this checked: now try its main action and a reload (the "
277
- + "app README's check) before you tell the user what it does.");
276
+ + await checkOnBoard(a.card) + ". Errors and fit are all this checked: now use it as the user would (load the "
277
+ + "test-your-app skill) before you tell the user what it does.");
278
278
  },
279
279
  });
280
280
 
@@ -296,11 +296,11 @@ export default function (pi: ExtensionAPI) {
296
296
  b[a.path] = a.value;
297
297
  const errors = problems(r.blocks, r.made_from);
298
298
  if (errors.length) return result("error: " + errors.join("; "));
299
- cards.change(a.card, r, agent, `${agent} set ${a.path} of ${a.block} in ${a.card}`);
299
+ cards.change(a.card, r, agent, `set ${a.path} of ${a.block} in ${a.card}`);
300
300
  return result("updated");
301
301
  }
302
302
  setPath(r.data, a.path, a.value);
303
- cards.change(a.card, r, agent, `${agent} changed ${a.path} in ${a.card}`);
303
+ cards.change(a.card, r, agent, `changed ${a.path} in ${a.card}`);
304
304
  return result("updated");
305
305
  },
306
306
  });
@@ -313,7 +313,7 @@ export default function (pi: ExtensionAPI) {
313
313
  const locked = cards.locked(a.card);
314
314
  if (locked) return result(locked);
315
315
  const users = cards.copyIntoUsers(a.card), agent = pi.getSessionName() ?? "agent";
316
- cards.change(a.card, null, agent, `${agent} removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
316
+ cards.change(a.card, null, agent, `removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
317
317
  return result("removed" + (users.length ? `; ${users.join(", ")} read from it, so they got a copy of its data` : ""));
318
318
  },
319
319
  });
@@ -339,30 +339,30 @@ export default function (pi: ExtensionAPI) {
339
339
  if (!r || !type?.startsWith("card_")) return;
340
340
  if (type === "card_close") {
341
341
  const users = cards.copyIntoUsers(id);
342
- cards.change(id, null, "user", `you closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
342
+ cards.change(id, null, "user", `closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
343
343
  }
344
344
  else if (type === "card_move") {
345
345
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
346
- r.place = msg.place; cards.change(id, r, "user", `you moved ${id}`);
346
+ r.place = msg.place; cards.change(id, r, "user", `moved ${id}`);
347
347
  } else if (type === "card_board" && host.api.board?.has(msg.board)) { // dropped on another board's thumbnail
348
348
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
349
- r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `you moved ${id} to board ${msg.board}`);
350
- } else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `you changed ${msg.path} in ${id}`); }
349
+ r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `moved ${id} to board ${msg.board}`);
350
+ } else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `changed ${msg.path} in ${id}`); }
351
351
  else if (type === "card_prop") {
352
352
  for (const b of r.blocks ?? []) if (b.id === msg.block && !STRUCTURE.includes(msg.prop)) b[msg.prop] = msg.value;
353
- cards.change(id, r, "user", `you set ${msg.prop} of ${msg.block} in ${id}`);
354
- } else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user", `you ${r.pinned ? "pinned" : "unpinned"} ${id}`); }
353
+ cards.change(id, r, "user", `set ${msg.prop} of ${msg.block} in ${id}`);
354
+ } else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user", `${r.pinned ? "pinned" : "unpinned"} ${id}`); }
355
355
  else if (type === "card_keep" && r.options) {
356
356
  const o = r.options[Number(msg.index ?? 0)];
357
357
  delete r.options;
358
358
  if (o) {
359
359
  delete r.blocks; delete r.app;
360
360
  Object.assign(r, o.app ? { app: o.app } : { blocks: o.blocks }, { data: o.data });
361
- cards.change(id, r, "user", `you kept ${o.label} of ${id}`);
361
+ cards.change(id, r, "user", `kept ${o.label} of ${id}`);
362
362
  }
363
363
  } else if (type === "card_action") {
364
364
  r.data = msg.data ?? r.data;
365
- cards.change(id, r, "user", `you used ${msg.name} in ${id}`);
365
+ cards.change(id, r, "user", `used ${msg.name} in ${id}`);
366
366
  host.send("user", r.owner, `[card action] ${msg.name} on card '${id}' (${msg.block === "app" ? "its app" : `block ${msg.block}`})\n`
367
367
  + `context: ${JSON.stringify(msg.context ?? {})}\ndata: ${JSON.stringify(r.data)}`);
368
368
  }
@@ -3,6 +3,8 @@
3
3
  // Files dragged from the computer onto the page: uploaded (POST /file/upload), then a "file-dropped"
4
4
  // DOM event {x, y, files: [{name, path, size}]} at the drop spot (the note extension opens its note box
5
5
  // there). Dropped onto the panel itself: only stored.
6
+ // Pasted (Ctrl+V / ⌘V) with files on the clipboard: the same as a drop where the pointer last was (copied
7
+ // images, all named image.png, become pasted-<date>-<time>.png). Text pastes as usual.
6
8
  // A row dragged out of the panel onto the page: the same event, with that file.
7
9
  // A click on a row, or files dropped from the computer (the first of them): a "file-open" DOM event
8
10
  // {file, row} (the viewer extension opens a preview window attached to the row; rows carry data-path).
@@ -42,7 +44,11 @@ export default async function (page) {
42
44
  #side .file-bar button { font: inherit; color: var(--text); background: var(--glass-strong); border: 1px solid var(--line);
43
45
  border-radius: 8px; padding: 3px 10px; cursor: pointer; }
44
46
  #side .file-bar .delete { color: #fff; background: var(--danger); border-color: transparent; }
45
- #side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }`;
47
+ #side .file-row:hover, #side .file-row.viewing { background: var(--hover); color: var(--text); }
48
+ .file-uploading { position: fixed; z-index: 200000; max-width: 240px; padding: 5px 10px; border-radius: 10px; font-size: 12px;
49
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none;
50
+ background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
51
+ border: 1px solid var(--edge); box-shadow: var(--shadow); }`;
46
52
  document.head.append(style);
47
53
 
48
54
  const toggle = document.createElement("button");
@@ -129,15 +135,48 @@ export default async function (page) {
129
135
  side.removeAttribute("data-over");
130
136
  const { clientX: x, clientY: y } = e;
131
137
  if (k === "row") return onPanel(e) || dropped(x, y, [JSON.parse(e.dataTransfer.getData(ROW))]);
132
- const stored = [];
133
- for (const f of [...e.dataTransfer.files].slice(0, 10)) {
138
+ store([...e.dataTransfer.files], x, y, onPanel(e));
139
+ });
140
+
141
+ // Upload files (at most MAX), then preview the first and fire "file-dropped" at (x, y), unless they landed on the panel
142
+ const MAX = 10;
143
+ async function store(all, x, y, panel, names = all.map((f) => f.name)) {
144
+ if (all.length > MAX) page.notice([`You added ${all.length} files: only the first ${MAX} were kept.`]);
145
+ const stored = [], files = all.slice(0, MAX), size = files.reduce((n, f) => n + f.size, 0);
146
+ const tag = Object.assign(document.createElement("div"), { className: "file-uploading",
147
+ textContent: `Uploading ${files.length > 1 ? `${files.length} files` : names[0]} · ${Math.ceil(size / 1024)} KB` });
148
+ Object.assign(tag.style, { left: `${Math.min(x + 14, innerWidth - 260)}px`, top: `${Math.min(y + 16, innerHeight - 40)}px` });
149
+ const slow = setTimeout(() => document.body.append(tag), 500); // only when it takes a while
150
+ for (const [i, f] of files.entries()) {
134
151
  try {
135
- const out = await (await fetch(`/file/upload?name=${encodeURIComponent(f.name)}`, { method: "POST", body: f })).json();
152
+ const out = await (await fetch(`/file/upload?name=${encodeURIComponent(names[i])}`, { method: "POST", body: f })).json();
136
153
  if (out.path) stored.push({ name: out.path.split("/").pop(), path: out.path, size: out.size });
137
- else page.note(`Could not upload ${f.name}: ${out.error}`);
138
- } catch (err) { page.note(`Could not upload ${f.name}: ${err}`); }
154
+ else page.notice([`Could not upload ${names[i]}: ${out.error}`]);
155
+ } catch (err) { page.notice([`Could not upload ${names[i]}: ${err}`]); }
139
156
  }
157
+ clearTimeout(slow); tag.remove();
140
158
  if (stored.length) opened(stored[0]); // the list has its row by now (the "file" event comes before the reply)
141
- if (stored.length && !onPanel(e)) dropped(x, y, stored);
159
+ if (stored.length && !panel) dropped(x, y, stored);
160
+ }
161
+
162
+ // Paste (Ctrl+V / ⌘V): files on the clipboard are added as if dropped where the pointer last was.
163
+ // Text pastes as usual. A paste inside a card's app goes to that app (its own page): Chalky never hears it.
164
+ let pointer = { x: innerWidth / 2, y: innerHeight / 2 };
165
+ document.addEventListener("pointermove", (e) => { pointer = { x: e.clientX, y: e.clientY }; }, { passive: true });
166
+ document.addEventListener("paste", (e) => {
167
+ const files = [...(e.clipboardData?.files || [])];
168
+ if (!files.length) { // a Linux file manager copies the file's address, not the file
169
+ const text = e.clipboardData?.getData("text/plain") ?? "";
170
+ const typing = e.target.closest?.("input, textarea, [contenteditable]");
171
+ if (!typing && /^file:\/\//.test(text.trim())) page.notice(["You copied a file's address, not the file. Drag the file onto the page instead."]);
172
+ return;
173
+ }
174
+ e.preventDefault();
175
+ const d = new Date(), p = (n) => String(n).padStart(2, "0");
176
+ const stamp = `pasted-${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
177
+ let n = 0; // every copied image arrives as image.png
178
+ const names = files.map((f) => (!f.name || f.name === "image.png") ? `${stamp}${n++ ? `-${n}` : ""}.${f.type.split("/")[1] || "png"}` : f.name);
179
+ const { x, y } = pointer;
180
+ store(files, x, y, side.contains(document.elementFromPoint(x, y)), names);
142
181
  });
143
182
  }
@@ -3,7 +3,8 @@
3
3
  // the page (from the computer: uploaded here, listed, and a "file-dropped" DOM event so note's browser.js opens its note
4
4
  // box there; onto the panel itself: only stored; a row from the panel: the same event with that file).
5
5
  // The agent always knows the folder (its prompt section); files it saves there show in the panel too (the list is
6
- // sent again when an agent's turn is over: pi's agent_settled).
6
+ // sent again after each of its tool calls, pi's tool_execution_end, if it changed: a file it saves shows at once,
7
+ // while it is still working).
7
8
  // POST /file/upload?name=<file name> the file's bytes: saved in DIR (a safe, new name), returns {path, size}
8
9
  // GET /file [{name, path, size}], newest first; files in folders too ("q3/costs.xlsx")
9
10
  // POST /file/delete {names: [...]} (names as listed): deleted for good, returns {deleted, errors}
@@ -37,7 +38,7 @@ function changed(always = false) { // tell the pages, if the list is not what t
37
38
  }
38
39
 
39
40
  export default function (pi: ExtensionAPI) {
40
- pi.on("agent_settled", () => changed()); // the agent may have saved files there
41
+ pi.on("tool_execution_end", () => changed()); // the agent may have saved files there
41
42
  if (!host.shared.has("file-last")) host.shared.set("file-last", JSON.stringify(listing()));
42
43
 
43
44
  host.routes.set("/file", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(listing())));
@@ -24,8 +24,8 @@ one design, not separate requests: they share one look, and often each other's d
24
24
  on the page, with no agent in between. Separate block cards with {"ref"} props are for a frame that only
25
25
  shows another's data (refs only read). Don't change the page's own code (page.html, the browser.js files) to
26
26
  link frames.
27
- - Fill a frame with card_show(card=<its id>, place=<its place>, ...): its dashed box goes when that
28
- card appears. Keep the place: the user chose where and how big.
27
+ - Fill a frame with card_show(place=<its place>, ...), with any card id (a name for what it is): its
28
+ dashed box goes when a card appears exactly there. Keep the place: the user chose where and how big.
29
29
  - Split the work when it pays: several frames that are each real work and don't depend on each other can
30
30
  go to helpers in parallel (team_spawn, or idle agents you have): tell each one its frame, and what done
31
31
  means: "card <id> shown at <place>, doing <their words>". Say the shared look in each message
@@ -3,12 +3,12 @@
3
3
  // so it sees the whole layout and decides how to split the work (alone, or with helpers: README.md, not code).
4
4
  // A frame is {x, y, width, height, text, sent, to?, board} (main-space px, where card_show's place puts a card;
5
5
  // board: the one it was drawn on, board/; Implement sends the frames of the board shown), with an
6
- // id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(card="frame-1", place=<its place>);
7
- // when that card appears (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
6
+ // id (frame-1, frame-2, ...: never reused). An agent fills it with card_show(place=<its place>) (any card id);
7
+ // when a card appears at its place (or with its id) (a card watcher, host.api.card.watch), the frame is done and its dashed box goes. The user
8
8
  // can remove a frame (its ✕); if it was sent, its agent is told. Until a frame is sent, its corner resizes it.
9
9
  // GET /frame {id: frame}
10
10
  // page messages frame_add {x, y, width, height, board}, frame_place {id, x, y, width, height}, frame_text {id, text},
11
- // frame_remove {id}, frame_send {to, board}
11
+ // frame_remove {id}, frame_send {to, board, by?} (by: the agent whose code pressed Implement)
12
12
  // event "frame" {frames: {id: frame}} to every open page, when they changed
13
13
  // Saved in data/frame.json as {last: <last id number>, frames: {id: frame}}; shared by every agent (host.shared).
14
14
  import fs from "node:fs";
@@ -32,10 +32,17 @@ const place = (f: any) => JSON.stringify({ x: f.x, y: f.y, width: f.width, heigh
32
32
 
33
33
  export default function (pi: ExtensionAPI) {
34
34
 
35
- // a frame is done when a card with its id appears (after all extensions loaded: the card one may come later)
35
+ // a frame is done when a card appears at its place on its board, or with its id (after all extensions loaded: the
36
+ // card one may come later). The place is what the user chose; agents often give the card a name of its own.
36
37
  pi.on("session_start", () => {
37
38
  host.api.card?.watch("frame", (sid: string) => {
38
- if (frames[sid] && host.shared.get("card")?.cards[sid]) { delete frames[sid]; changed(); }
39
+ const card = host.shared.get("card")?.cards[sid];
40
+ if (!card) return;
41
+ const at = (f: any) => card.place && (card.board ?? 1) === (f.board ?? 1)
42
+ && ["x", "y", "width", "height"].every((k) => Math.round(card.place[k]) === f[k]);
43
+ const done = Object.keys(frames).filter((id) => id === sid || at(frames[id]));
44
+ for (const id of done) delete frames[id];
45
+ if (done.length) changed();
39
46
  });
40
47
  host.api.board?.onRemove("frame", (n: number) => { // a removed board (board/) takes its frames with it
41
48
  const ids = Object.keys(frames).filter((id) => (frames[id].board ?? 1) === n);
@@ -70,9 +77,11 @@ export default function (pi: ExtensionAPI) {
70
77
  if (!ready.length) return;
71
78
  for (const [, f] of ready as any) Object.assign(f, { sent: true, to });
72
79
  changed();
73
- const lines = [`[frames on the page] the user laid out ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
80
+ // by: an agent's code pressed Implement (page/browser.js), not the user
81
+ const who = !msg.by ? "the user laid out" : msg.by === to ? "you pressed Implement for" : `${msg.by} pressed Implement for`;
82
+ const lines = [`[frames on the page] ${who} ${ready.length} frame${ready.length > 1 ? "s" : ""} on board ${board} to fill:`,
74
83
  ...ready.map(([id, f]: any) => ` ${id}: "${f.text}" · place=${place(f)}`),
75
- ` Fill each with card_show(card=<its id>, place=<its place>, board=${board}, ...); its dashed box goes when it appears.`];
84
+ ` Fill each with card_show(place=<its place>, board=${board}, ...) (any card id): its dashed box goes when a card appears there.`];
76
85
  const waiting = Object.keys(frames).filter((id) => frames[id].sent && !ready.some(([r]) => r === id));
77
86
  if (waiting.length) lines.push(` Sent before and not filled yet: ${waiting.join(", ")}.`);
78
87
  team.send("user", to, lines.join("\n"));
@@ -88,7 +88,9 @@ export default function (pi: ExtensionAPI) {
88
88
  pi.on("session_start", () => {
89
89
  if (!host.shared.has("history")) host.shared.set("history", new History(host.shared.get("card")));
90
90
  host.api.history = host.shared.get("history");
91
- host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { if (what) host.api.history.add(by, what); });
91
+ host.api.card?.watch("history", (_id: string, by?: string, what?: string) => { // the card's words don't say who
92
+ if (what) host.api.history.add(by, `${by === "user" ? "you" : by} ${what}`);
93
+ });
92
94
  });
93
95
  const shared = (): History | undefined => host.shared.get("history");
94
96
 
@@ -32,4 +32,6 @@ thing has partAt), its text, and the file it comes from. Act on that exact thing
32
32
  pointer ("this one") use the pen (doodle_mark) instead.
33
33
  - When you did what a note asked, note_done(id): its pin turns into a faded ✓, which the user can reopen or delete. If your
34
34
  answer leaves anything to them (you suggest something, ask, or offer a choice), leave the note open: their reply
35
- comes in the same thread, and you close it once that is settled. The user can close one too (✓ Done); you get an fyi.
35
+ comes in the same thread, and you close it once that is settled. The user can close one too (✓) or delete it (✕);
36
+ you get an fyi. A note you started that is no longer needed (it explained a step, the step is past) can go for
37
+ good: note_done(id, delete: true). Notes the user or another agent started you only close.
@@ -9,7 +9,8 @@
9
9
  // tells every open page to pin it, with its drawing). Esc or the button turns it off.
10
10
  // Pins: a numbered pin per note (the agents' in their colour), on the thing it is on, scrolling with the main space
11
11
  // and moving with its card. Click one for its thread: who wrote what, a reply box (Enter sends it to the note's
12
- // agent), ✓ Done; a closed note stays as a faded ✓ (Reopen, or Delete for good). An agent's reply opens the bubble while
12
+ // agent), ✓ (close) and ✕ (delete) at its top right; a closed note stays as a faded ✓ (Reopen). ✕ asks nothing: the pin
13
+ // goes, a notice offers Undo for a few seconds, and only then is the delete sent. An agent's new note, or its reply, opens the bubble while
13
14
  // the note stays open. Show marks (doodle/)
14
15
  // hides the pins with the other marks.
15
16
  // The same note box opens for two more things (Enter on an empty box sends its faded suggestion):
@@ -246,6 +247,9 @@ export default async function (page) {
246
247
  .note-pin .bubble .busy { color: var(--muted, #888); font-style: italic; }
247
248
  .note-pin .bubble textarea { width: 100%; box-sizing: border-box; min-height: 48px; resize: vertical; font: inherit; padding: 6px 8px;
248
249
  border-radius: 8px; border: 1px solid rgba(0, 0, 0, .15); outline: none; background: rgba(255, 255, 255, .8); }
250
+ .note-pin .bubble .top { float: right; display: flex; gap: 2px; margin: -4px -6px 0 6px; }
251
+ .note-pin .bubble .top button { width: 24px; height: 24px; border-radius: 6px; font-size: 13px; color: var(--soft, #555); }
252
+ .note-pin .bubble .top button:hover { background: var(--hover, rgba(0, 0, 0, .05)); color: var(--text, #1d1d1f); }
249
253
  .note-pin .bubble .acts { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
250
254
  .note-pin .bubble .acts button { padding: 3px 10px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(0, 0, 0, .12); }
251
255
  .note-pin .bubble .acts button.primary { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
@@ -262,7 +266,21 @@ export default async function (page) {
262
266
  if (card) { const r = card.getBoundingClientRect(), c = inMain(r.left, r.top); return { x: c.x + n.anchor.dx, y: c.y + n.anchor.dy }; }
263
267
  return n.at ?? { x: 0, y: 0 };
264
268
  }
269
+ // Deleting: the pin goes at once, and a notice offers Undo for UNDO_MS; only then is the delete sent (so an undo
270
+ // needs nothing from the server, and other pages keep the note until it is really gone)
271
+ const UNDO_MS = 6000;
272
+ const leaving = {}; // id -> timer, while its undo is offered
273
+ function remove(id) {
274
+ openId = null;
275
+ pins[id]?.remove(); delete pins[id];
276
+ const undo = Object.assign(document.createElement("button"), { type: "button", className: "go", textContent: "Undo" });
277
+ const notice = page.notice(["Note deleted", undo], () => {});
278
+ const gone = () => { delete leaving[id]; notice.close(); page.send({ type: "note_delete", id }); };
279
+ leaving[id] = setTimeout(gone, UNDO_MS);
280
+ undo.onclick = () => { clearTimeout(leaving[id]); delete leaving[id]; notice.close(); if (notes[id]) draw(id); };
281
+ }
265
282
  function draw(id) {
283
+ if (id in leaving) return; // deleted, its undo still offered
266
284
  const n = notes[id];
267
285
  let el = pins[id];
268
286
  if (!el) {
@@ -283,6 +301,7 @@ export default async function (page) {
283
301
  }
284
302
  const b = Object.assign(document.createElement("button"), { className: "pin", type: "button", textContent: n.state === "open" ? id.slice(1) : "✓",
285
303
  title: `${n.thread[0]?.text ?? ""}${n.state === "open" ? "" : " (closed)"}` });
304
+ b.setAttribute("aria-label", `note ${id}${n.state === "open" ? "" : " (closed)"}: ${n.thread[0]?.text ?? ""}`); // what read() and screen readers say
286
305
  Object.assign(b.style, { left: `${p.x}px`, top: `${p.y}px` });
287
306
  b.onclick = (e) => { e.stopPropagation(); openId = openId === id ? null : id; for (const k of Object.keys(pins)) draw(k); };
288
307
  el.append(b);
@@ -309,6 +328,16 @@ export default async function (page) {
309
328
  b.onclick = fn;
310
329
  acts.append(b);
311
330
  };
331
+ // top right: ✓ closes (a faded pin, can be reopened), ✕ deletes (no question: a few seconds to undo instead)
332
+ const top = Object.assign(document.createElement("div"), { className: "top" });
333
+ const corner = (text, title, fn) => {
334
+ const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, title });
335
+ b.onclick = fn;
336
+ top.append(b);
337
+ };
338
+ if (n.state === "open") corner("✓", "Done: close this note", () => { openId = null; page.send({ type: "note_close", id }); });
339
+ corner("✕", "Delete this note", () => remove(id));
340
+ d.prepend(top);
312
341
  if (n.state === "open") {
313
342
  const ta = Object.assign(document.createElement("textarea"), { placeholder: "Reply… (Enter sends)", value: drafts[id] ?? "" });
314
343
  ta.oninput = () => { drafts[id] = ta.value; };
@@ -319,13 +348,9 @@ export default async function (page) {
319
348
  if (e.key === "Escape") { openId = null; draw(id); }
320
349
  };
321
350
  d.append(ta);
322
- button("✓ Done", () => { openId = null; page.send({ type: "note_close", id }); });
323
351
  button("Reply", go, true);
324
352
  setTimeout(() => ta.focus(), 0);
325
- } else {
326
- button("Delete", () => { if (confirm("Delete this note for good?")) { openId = null; page.send({ type: "note_delete", id }); } });
327
- button("Reopen", () => page.send({ type: "note_reopen", id }));
328
- }
353
+ } else button("Reopen", () => page.send({ type: "note_reopen", id }));
329
354
  d.append(acts);
330
355
  return d;
331
356
  }
@@ -338,8 +363,9 @@ export default async function (page) {
338
363
  setInterval(layout, 1500);
339
364
  page.on("note_pin", ({ id, type, agent, ts, ...n }) => {
340
365
  const reply = notes[id] && n.thread.length > notes[id].thread.length && n.thread.at(-1).by !== "user" && n.state === "open"; // an answer still waiting on the user
366
+ const started = !notes[id] && n.by !== "user" && n.state === "open"; // an agent's new note (the notes there at load come from /note/all)
341
367
  notes[id] = n;
342
- if (reply && openId !== id) openId = id; // an agent answered in the thread: show it
368
+ if ((reply || started) && openId !== id) openId = id; // an agent wrote: show it open, so the user sees it
343
369
  if (openId === id && n.state !== "open" && n.thread.at(-1)?.by !== "user") openId = null; // and closed it: done, so out of the way
344
370
  draw(id);
345
371
  });
@@ -4,5 +4,6 @@ A note is a thread pinned on a thing; you and the user can both start one. The u
4
4
  - Change that exact thing. If its text is data (a row, a score), change the card's data, not the block.
5
5
  - Answer in the note's thread (note_reply), not in chat. Ask or explain on a thing with note_add(on, text).
6
6
  - When you did what it asked, call note_done(id): the pin stays as a faded ✓ the user can reopen. If your answer
7
- leaves anything to them (a suggestion, a question, a choice), leave it open for their reply.
7
+ leaves anything to them (a suggestion, a question, a choice), leave it open for their reply. A note you started
8
+ that has served its purpose can go: note_done(id, delete: true).
8
9
  Before you handle your first note, read README.md.