@chalky/chalky 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/app/SYSTEM.md +31 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +398 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +166 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +85 -0
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +247 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +112 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +364 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +291 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/mcp.json +3 -0
- package/app/settings.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +281 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Browser half of the commands extension: the agents' pi commands (GET /commands) join the "/" list in the chat
|
|
2
|
+
// input. One runs on the agent picked in "to" (agent1 if none), with the words after it.
|
|
3
|
+
export default function (page) {
|
|
4
|
+
const target = () => document.getElementById("to")?.value || "main";
|
|
5
|
+
page.on("commands", (ev) => page.note(`${ev.agent}: ${ev.text}`));
|
|
6
|
+
fetch("/commands").then((r) => r.ok ? r.json() : []).then((list) => {
|
|
7
|
+
for (const c of list) page.command(c.name, (rest) => {
|
|
8
|
+
const agent = target() === "main" ? "agent1" : target();
|
|
9
|
+
page.note(`/${c.name} → ${agent}`);
|
|
10
|
+
page.send({ type: "command", agent, text: `/${c.name} ${rest}`.trim() });
|
|
11
|
+
}, c.description);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Agent commands: pi's built-in "/" commands live in its terminal UI only, so an SDK session doesn't have them. Each
|
|
2
|
+
// one here is a pi command (pi.registerCommand) that calls what the built-in calls (pi's docs/extensions.md).
|
|
3
|
+
// The page lists every pi command an agent has and sends one to the agent picked in "to" (pi runs it at once, even
|
|
4
|
+
// mid-run: session.prompt checks for commands first).
|
|
5
|
+
// GET /commands -> [{name, description}] (agent1's, from app/extensions)
|
|
6
|
+
// page message command {agent, text: "/name args"}
|
|
7
|
+
// page event commands {agent, text} -> a plain note (e.g. /compact with nothing new)
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const host = (globalThis as any).chalky;
|
|
13
|
+
|
|
14
|
+
export default function (pi: ExtensionAPI) {
|
|
15
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
|
|
16
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.commands = guide; });
|
|
17
|
+
|
|
18
|
+
// pi's /compact: summarize the conversation now (the page shows "history compacted" when it's done). Whatever its
|
|
19
|
+
// size: keepRecentTokens is 0 for this one (in memory, not saved), so all but the newest message is summarized;
|
|
20
|
+
// the automatic compaction keeps app/settings.json's.
|
|
21
|
+
pi.registerCommand("compact", {
|
|
22
|
+
description: "Summarize the agent's conversation now (words after it: what the summary should focus on)",
|
|
23
|
+
handler: async (args, ctx) => {
|
|
24
|
+
const agent = pi.getSessionName()!;
|
|
25
|
+
// nothing said since the last compaction (its note is the newest entry): skip, it would only re-summarize the summary
|
|
26
|
+
const last = ctx.sessionManager.getLeafEntry();
|
|
27
|
+
if (last?.type === "compaction" || (last?.type === "custom_message" && last.customType === "compacted"))
|
|
28
|
+
return host.broadcast("commands", { agent, text: "nothing new since the last compaction" });
|
|
29
|
+
const settings = host.agents.get(agent).session.settingsManager;
|
|
30
|
+
const keep = settings.getSettings().compaction?.keepRecentTokens;
|
|
31
|
+
settings.applyOverrides({ compaction: { keepRecentTokens: 0 } });
|
|
32
|
+
const restore = () => settings.applyOverrides({ compaction: { keepRecentTokens: keep } });
|
|
33
|
+
ctx.compact({ customInstructions: args.trim() || undefined, onComplete: restore,
|
|
34
|
+
onError: (e) => { restore(); host.broadcast("error", { agent, text: `compact: ${e.message}` }); } });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// pi's /thinking: how hard this agent thinks (sent as reasoning_effort; "off": nothing is sent, the model's own
|
|
39
|
+
// default). pi saves it in the agent's session file, so it stays after a restart.
|
|
40
|
+
pi.registerCommand("thinking", {
|
|
41
|
+
description: "Set how hard the agent thinks: off, minimal, low, medium, high (none: show it)",
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const agent = pi.getSessionName()!;
|
|
44
|
+
const session = host.agents.get(agent).session;
|
|
45
|
+
const levels = session.getAvailableThinkingLevels().join(", ");
|
|
46
|
+
const level = args.trim();
|
|
47
|
+
if (level && !session.getAvailableThinkingLevels().includes(level))
|
|
48
|
+
return host.broadcast("error", { agent, text: `thinking: "${level}" isn't one of ${levels}` });
|
|
49
|
+
if (level) pi.setThinkingLevel(level);
|
|
50
|
+
host.broadcast("commands", { agent, text: `thinking ${pi.getThinkingLevel()}${level ? "" : ` (${levels})`}` });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// pi's /new: an empty conversation for this agent (the swarm's reset: a new session file, role and thinking kept;
|
|
55
|
+
// the old file stays). After this command returns: the reset replaces the session that is running it.
|
|
56
|
+
pi.registerCommand("new", {
|
|
57
|
+
description: "Start a new, empty conversation for the agent (its role stays; the old one is kept on disk)",
|
|
58
|
+
handler: async () => {
|
|
59
|
+
const agent = pi.getSessionName()!;
|
|
60
|
+
const thinking = pi.getThinkingLevel();
|
|
61
|
+
setTimeout(async () => {
|
|
62
|
+
const error = await host.api.swarm.reset([agent]);
|
|
63
|
+
if (error) return host.broadcast("error", { agent, text: `new: ${error}` });
|
|
64
|
+
host.agents.get(agent).session.setThinkingLevel(thinking);
|
|
65
|
+
host.broadcast("commands", { agent, text: "new conversation" });
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// after any compaction (/compact or pi's automatic one): a note right after the summary, so the agent knows it just
|
|
71
|
+
// happened and where the full conversation still is (pi only appends to the session file; nothing is deleted)
|
|
72
|
+
pi.on("session_compact", (event, ctx) => {
|
|
73
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
74
|
+
pi.sendMessage({ customType: "compacted", display: false, content: `[note from system · ${time}]\nYour ` +
|
|
75
|
+
`conversation was just compacted (${event.reason}): what came before is the summary above. The full ` +
|
|
76
|
+
`conversation is still in ${ctx.sessionManager.getSessionFile()} when the summary isn't enough. Its lines are ` +
|
|
77
|
+
`long JSON entries, so print only around a match: grep -oE '.{0,300}words.{0,300}' <file>` });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
host.routes.set("/commands", (_req: any, res: any) => {
|
|
81
|
+
// only ours (app/extensions): pi's built-in extensions' commands are terminal screens (e.g. its MCP's /mcp)
|
|
82
|
+
const runner = host.agents.get("agent1")?.session.extensionRunner;
|
|
83
|
+
const list = (runner?.getRegisteredCommands() ?? []).filter((c: any) => c.sourceInfo?.path?.startsWith(host.app))
|
|
84
|
+
.map((c: any) => ({ name: c.invocationName, description: c.description ?? "" }));
|
|
85
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(list));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
host.pageHandlers.set("commands", async (msg: any) => {
|
|
89
|
+
if (msg.type !== "command") return;
|
|
90
|
+
const name = host.agents.has(msg.agent) ? msg.agent : "agent1";
|
|
91
|
+
// a running turn is stopped first, on purpose (compact would abort it anyway; this way it isn't shown as an error)
|
|
92
|
+
if (host.agents.get(name).session.isStreaming) await host.abort(name);
|
|
93
|
+
try { await host.agents.get(name).session.prompt(msg.text); }
|
|
94
|
+
catch (e: any) { host.broadcast("error", { agent: name, text: `${msg.text}: ${e.message}` }); }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Doodles (the doodle extension)
|
|
2
|
+
The user can draw on the page (lines, arrows, circles, words in handwriting), click through it while drawing,
|
|
3
|
+
then send it all with a few words: "[doodles on the UI] <their words>", then the steps, in order. Each step
|
|
4
|
+
is a picture of the screen as they saw it, with their drawing on it (cropped to the drawing; the pictures
|
|
5
|
+
are attached to the message, in step order), which part of the page it shows (main-space px), the words they
|
|
6
|
+
wrote on it (exactly), then what they clicked to get to the next step. The drawing is only in the picture.
|
|
7
|
+
- Read it as a story: step 1 is how it is and what they marked; their clicks lead to step 2; and so on. A
|
|
8
|
+
step with nothing drawn after clicks is how the screen looked after them: often the point ("after Save,
|
|
9
|
+
this should show the date").
|
|
10
|
+
- Look at every picture closely: the drawing is the message. A line from A to B links them ("this goes
|
|
11
|
+
there", "this feeds that"); a circle or a box points at the thing to change; a cross removes; handwriting
|
|
12
|
+
says what. Find what a mark is on from the picture (its text, its look) and the page you know.
|
|
13
|
+
- To place something where they drew (on empty space): the picture's area maps to the page, so a mark's
|
|
14
|
+
spot in the picture is a spot on the page; ui_render(..., place=<that box>).
|
|
15
|
+
- If the drawing could mean two quite different things, propose one in a line and ask; otherwise do it.
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Browser half of the doodle extension (Python's doodle.js, unchanged but for this header): three tools in the palette, a marker, Text and an eraser. The marker and
|
|
2
|
+
// Text can be on together; the eraser (like any other tool) turns them off.
|
|
3
|
+
// marker drag draws on the page, over surfaces and inside App frames (same-origin). A press that doesn't move
|
|
4
|
+
// is an ordinary click, so double and triple clicks still select text. Typing with the pointer over the
|
|
5
|
+
// page and no text box focused puts words in handwriting there.
|
|
6
|
+
// Text (T) every click puts words in handwriting there; Enter or a click elsewhere ends them. With the
|
|
7
|
+
// marker on too, a drag draws and a click writes.
|
|
8
|
+
// eraser press or drag over a line or words to take them away whole; it also says "erase" (a page event,
|
|
9
|
+
// {x, y} in main-space px) so other tools take theirs away (frames: an unsent frame).
|
|
10
|
+
// click with the marker (Text off) works the page as usual. If there is drawing, it first becomes a step: a picture of the screen
|
|
11
|
+
// as it was when the button went down (before the click acts), cropped to the drawing, and the
|
|
12
|
+
// ink clears. The drawing goes only as the picture; typed words also go as text, exactly. Clicks
|
|
13
|
+
// are written down as words, between the steps.
|
|
14
|
+
// Enter in the message line (also with the marker off) sends the steps, the drawing still on the page and
|
|
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.
|
|
18
|
+
// Things with a drag of their own keep it: a surface's label (moves it), its resize corner, lists to pick from,
|
|
19
|
+
// sliders, draggable things, frames; text boxes don't (a drag that starts on one draws). Ctrl+Z takes back
|
|
20
|
+
// the last mark; Esc turns the tools off (ink stays).
|
|
21
|
+
// Pictures: the tab is shared once (the screen extension), when the marker or Text is first turned on; without
|
|
22
|
+
// it, steps go as words only. The steps so far show in the message line ("1 → ☆ Week 2 · 2 marks"): hover one for its picture, click it to see it big, ✕ drops it.
|
|
23
|
+
// Ink and words live in the main space (slot "top"), in main-space px, so they scroll with the surfaces.
|
|
24
|
+
export default async function (page) {
|
|
25
|
+
const area = page.slot("top");
|
|
26
|
+
const form = document.getElementById("f"), input = document.getElementById("text");
|
|
27
|
+
const SVG = "http://www.w3.org/2000/svg", INK = "#e5397a";
|
|
28
|
+
// The cursor: a marker (white edge, so it shows on dark too); its tip (3, 21) is where the line starts.
|
|
29
|
+
const PEN = `url("data:image/svg+xml,${encodeURIComponent(
|
|
30
|
+
`<svg xmlns="${SVG}" width="24" height="24" viewBox="0 0 24 24"><path d="M3 21l1.6-5.2L16.2 4.2a2.2 2.2 0 013.1 3.1L7.7 18.9z" ` +
|
|
31
|
+
`fill="#fff" stroke="#1d1d1f" stroke-width="1.4" stroke-linejoin="round"/><path d="M3 21l1.6-5.2 3.1 3.1z" fill="${INK}"/></svg>`)}") 3 21, crosshair`;
|
|
32
|
+
const RUB = `url("data:image/svg+xml,${encodeURIComponent( // the eraser cursor; its bottom-left edge (5, 20) rubs
|
|
33
|
+
`<svg xmlns="${SVG}" width="24" height="24" viewBox="0 0 24 24"><path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4l9.6-9.6a2.4 2.4 0 0 1 3.4 0` +
|
|
34
|
+
`l5.6 5.6a2.4 2.4 0 0 1 0 3.4L13 21z" fill="#fff" stroke="#1d1d1f" stroke-width="1.4" stroke-linejoin="round"/>` +
|
|
35
|
+
`<path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4L7.5 8.5l9 9L13 21z" fill="${INK}"/></svg>`)}") 5 20, crosshair`;
|
|
36
|
+
const CURSORS = `
|
|
37
|
+
.doodling, .doodling * { cursor: ${PEN}; }
|
|
38
|
+
.doodling :is(button, a, select, summary, label, [onclick], [role="button"], [role="tab"], .surface-label) { cursor: pointer; }
|
|
39
|
+
.doodling :is(input, textarea, [contenteditable="true"]) { cursor: text; }
|
|
40
|
+
.doodling .surface-resize { cursor: nwse-resize; }
|
|
41
|
+
.doodle-text:not(.doodling), .doodle-text:not(.doodling) * { cursor: text !important; }
|
|
42
|
+
.doodle-erase, .doodle-erase * { cursor: ${RUB} !important; }`;
|
|
43
|
+
const style = document.createElement("style");
|
|
44
|
+
style.textContent = CURSORS + `
|
|
45
|
+
#top:is(.doodling, .doodle-text, .doodle-erase) { z-index: 11; } /* above the messages panel (10), so lines can go there too */
|
|
46
|
+
#top > svg.doodle-ink { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; z-index: 100001; pointer-events: none; }
|
|
47
|
+
#top > svg.doodle-ink path { fill: none; stroke: ${INK}; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
48
|
+
#top > .doodle-note { position: absolute; z-index: 100001; font: 20px/1.2 "Segoe Print", "Bradley Hand", "Comic Sans MS", cursive; color: ${INK};
|
|
49
|
+
white-space: pre; outline: none; pointer-events: none; padding: 0 4px; min-width: 8px; }
|
|
50
|
+
#top > .doodle-note:empty::before { content: "type here…"; opacity: .5; }
|
|
51
|
+
#top > .doodle-note[contenteditable="true"] { pointer-events: auto; background: rgba(229, 57, 122, .1); border-radius: 6px; caret-color: ${INK}; }
|
|
52
|
+
#f .doodle-strip { display: flex; align-items: center; gap: 4px; flex: none; max-width: 45%; overflow-x: auto; }
|
|
53
|
+
#f .doodle-strip:empty { display: none; }
|
|
54
|
+
#f .doodle-chip { position: relative; flex: none; min-width: 24px; height: 24px; padding: 0 7px; box-sizing: border-box; border-radius: 7px;
|
|
55
|
+
display: grid; place-items: center; font-size: 12px; font-weight: 600; color: ${INK}; border: 1.5px solid ${INK};
|
|
56
|
+
background: rgba(229, 57, 122, .08); cursor: default; }
|
|
57
|
+
#f .doodle-chip.now { border-style: dashed; background: none; font-weight: 500; }
|
|
58
|
+
#f .doodle-chip .x { position: absolute; right: -6px; top: -6px; width: 14px; height: 14px; border-radius: 50%; background: var(--text);
|
|
59
|
+
color: #fff; font-size: 9px; line-height: 14px; text-align: center; display: none; cursor: pointer; }
|
|
60
|
+
#f .doodle-chip:hover .x { display: block; }
|
|
61
|
+
#f .doodle-click { flex: none; font-size: 12px; color: var(--muted); white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
|
62
|
+
.doodle-peek { position: fixed; z-index: 100; max-width: 360px; padding: 6px; border-radius: 12px; background: var(--glass-strong, #fff);
|
|
63
|
+
border: 1px solid var(--edge, #eee); box-shadow: 0 8px 30px rgba(0, 0, 0, .12); font-size: 12px; color: var(--muted); pointer-events: none; }
|
|
64
|
+
.doodle-peek img { display: block; max-width: 100%; max-height: 220px; border-radius: 6px; margin-bottom: 4px; }
|
|
65
|
+
#f .doodle-chip:not(.now) { cursor: zoom-in; }
|
|
66
|
+
.doodle-big { position: fixed; inset: 0; z-index: 200; display: grid; place-content: center; justify-items: center; gap: 10px;
|
|
67
|
+
background: rgba(0, 0, 0, .55); cursor: zoom-out; color: #fff; font-size: 13px; }
|
|
68
|
+
.doodle-big div { padding: 4px 12px; border-radius: 999px; background: rgba(0, 0, 0, .6); }
|
|
69
|
+
.doodle-big img { max-width: 92vw; max-height: 84vh; border-radius: 10px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); background: #fff; }`;
|
|
70
|
+
document.head.append(style);
|
|
71
|
+
|
|
72
|
+
const button = document.createElement("button");
|
|
73
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17c3-6 5-6 7-2s4 4 7-3 3-5 4-4"/></svg>`;
|
|
74
|
+
button.title = "Doodle: drag to draw, type to write, click to use the page; then Enter in the message line sends it all (Esc to stop)";
|
|
75
|
+
const textButton = document.createElement("button");
|
|
76
|
+
textButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7V5h14v2M12 5v14M9 19h6"/></svg>`;
|
|
77
|
+
textButton.title = "Text: click where the words go (Enter to finish); sent with the doodles, Enter in the message line (Esc to stop)";
|
|
78
|
+
const eraseButton = document.createElement("button");
|
|
79
|
+
eraseButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4l9.6-9.6a2.4 2.4 0 0 1 3.4 0` +
|
|
80
|
+
`l5.6 5.6a2.4 2.4 0 0 1 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`;
|
|
81
|
+
eraseButton.title = "Eraser: press or drag over lines, words or unsent frames to take them away (Esc to stop)";
|
|
82
|
+
page.slot("status").append(button, textButton, eraseButton);
|
|
83
|
+
const strip = Object.assign(document.createElement("div"), { className: "doodle-strip" });
|
|
84
|
+
form.insertBefore(strip, input);
|
|
85
|
+
const peek = Object.assign(document.createElement("div"), { className: "doodle-peek", hidden: true });
|
|
86
|
+
const big = Object.assign(document.createElement("div"), { className: "doodle-big", hidden: true }); // a step's picture, big
|
|
87
|
+
big.onclick = () => { big.hidden = true; };
|
|
88
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") big.hidden = true; });
|
|
89
|
+
document.body.append(peek, big);
|
|
90
|
+
|
|
91
|
+
let drawing = false, texting = false, erasing = false, on = false; // which tools are on; on: any of them
|
|
92
|
+
const docs = new Set([document]); // this page and every App frame's page: they all get the cursor
|
|
93
|
+
const classes = (el) => {
|
|
94
|
+
el.classList.toggle("doodling", drawing);
|
|
95
|
+
el.classList.toggle("doodle-text", texting);
|
|
96
|
+
el.classList.toggle("doodle-erase", erasing);
|
|
97
|
+
};
|
|
98
|
+
const set = (tool, v) => { // tool: "doodle" | "text" | "erase"
|
|
99
|
+
if (tool === "doodle") drawing = v; else if (tool === "text") texting = v; else erasing = v;
|
|
100
|
+
on = drawing || texting || erasing;
|
|
101
|
+
button.classList.toggle("on", drawing);
|
|
102
|
+
textButton.classList.toggle("on", texting);
|
|
103
|
+
eraseButton.classList.toggle("on", erasing);
|
|
104
|
+
classes(area);
|
|
105
|
+
for (const d of docs) if (d !== document && d.documentElement) classes(d.documentElement);
|
|
106
|
+
if (v) {
|
|
107
|
+
if (tool !== "erase") share();
|
|
108
|
+
document.dispatchEvent(new CustomEvent("tool-on", { detail: tool }));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const allOff = () => { for (const t of ["doodle", "text", "erase"]) set(t, false); };
|
|
112
|
+
button.onclick = () => set("doodle", !drawing);
|
|
113
|
+
textButton.onclick = () => set("text", !texting);
|
|
114
|
+
eraseButton.onclick = () => set("erase", !erasing);
|
|
115
|
+
document.addEventListener("tool-on", (e) => { // one tool at a time, except the marker and Text, which work together
|
|
116
|
+
const t = e.detail, pair = t === "doodle" || t === "text";
|
|
117
|
+
if (drawing && t !== "doodle" && !pair) set("doodle", false);
|
|
118
|
+
if (texting && t !== "text" && !pair) set("text", false);
|
|
119
|
+
if (erasing && t !== "erase") set("erase", false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---- pictures: the tab, shared once by the screen plugin (none without it: steps go as words) ----
|
|
123
|
+
let screen = null;
|
|
124
|
+
page.use("screen").then((s) => { screen = s; });
|
|
125
|
+
const share = () => screen?.share();
|
|
126
|
+
const frame = () => screen?.frame() ?? null; // the screen right now, as a canvas (null without a shared tab)
|
|
127
|
+
const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
|
|
128
|
+
|
|
129
|
+
// ---- where things are ----
|
|
130
|
+
const inMain = (x, y) => { // window px -> main-space px (as point.js)
|
|
131
|
+
const m = area.getBoundingClientRect();
|
|
132
|
+
return { x: x - m.left + area.scrollLeft, y: y - m.top + area.scrollTop };
|
|
133
|
+
};
|
|
134
|
+
const inWindow = (p) => {
|
|
135
|
+
const m = area.getBoundingClientRect();
|
|
136
|
+
return { x: p.x + m.left - area.scrollLeft, y: p.y + m.top - area.scrollTop };
|
|
137
|
+
};
|
|
138
|
+
const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
|
|
139
|
+
|
|
140
|
+
// ---- marks: lines and words ----
|
|
141
|
+
const ink = document.createElementNS(SVG, "svg"); // in the main space only while it has marks (#top:empty shows the welcome)
|
|
142
|
+
ink.classList.add("doodle-ink");
|
|
143
|
+
let marks = []; // [{el, points (main px), text (words only)}], oldest first
|
|
144
|
+
const show = () => { if (marks.some((m) => m.el.localName === "path" || m.el.localName === "g")) area.append(ink); else ink.remove(); render(); };
|
|
145
|
+
const f = (n) => n.toFixed(1);
|
|
146
|
+
const smooth = (pts) => { // through the midpoints, so a quick line isn't a row of corners
|
|
147
|
+
let d = `M${f(pts[0].x)} ${f(pts[0].y)}`;
|
|
148
|
+
for (let i = 1; i < pts.length - 1; i++) d += ` Q${f(pts[i].x)} ${f(pts[i].y)} ${f((pts[i].x + pts[i + 1].x) / 2)} ${f((pts[i].y + pts[i + 1].y) / 2)}`;
|
|
149
|
+
return d + ` L${f(pts.at(-1).x)} ${f(pts.at(-1).y)}`;
|
|
150
|
+
};
|
|
151
|
+
function note(p, first) { // words in handwriting at p (main px)
|
|
152
|
+
const n = Object.assign(document.createElement("div"), { className: "doodle-note", contentEditable: "true", textContent: first });
|
|
153
|
+
Object.assign(n.style, { left: `${p.x}px`, top: `${p.y - 14}px` });
|
|
154
|
+
area.append(n);
|
|
155
|
+
n.focus();
|
|
156
|
+
getSelection().selectAllChildren(n);
|
|
157
|
+
getSelection().collapseToEnd();
|
|
158
|
+
let done = false;
|
|
159
|
+
const finish = () => {
|
|
160
|
+
if (done) return;
|
|
161
|
+
done = true;
|
|
162
|
+
n.contentEditable = "false";
|
|
163
|
+
const t = n.textContent.trim();
|
|
164
|
+
if (!t) { n.remove(); return; }
|
|
165
|
+
marks.push({ el: n, points: [p, { x: p.x + n.offsetWidth, y: p.y + n.offsetHeight - 14 }], text: t });
|
|
166
|
+
show();
|
|
167
|
+
};
|
|
168
|
+
n.onkeydown = (e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); n.blur(); }
|
|
171
|
+
if (e.key === "Escape") { n.textContent = ""; n.blur(); }
|
|
172
|
+
};
|
|
173
|
+
n.onblur = finish;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- erasing: a mark goes whole when the eraser passes over it ----
|
|
177
|
+
function eraseAt(p) { // p: main px
|
|
178
|
+
const left = marks.filter((m) => {
|
|
179
|
+
if (m.text) { // words: their box
|
|
180
|
+
const x = m.el.offsetLeft, y = m.el.offsetTop;
|
|
181
|
+
return !(p.x >= x - 6 && p.x <= x + m.el.offsetWidth + 6 && p.y >= y - 6 && p.y <= y + m.el.offsetHeight + 6);
|
|
182
|
+
}
|
|
183
|
+
return !m.points.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12); // a line: near any of its points
|
|
184
|
+
});
|
|
185
|
+
if (left.length !== marks.length) {
|
|
186
|
+
for (const m of marks) if (!left.includes(m)) m.el.remove();
|
|
187
|
+
marks = left;
|
|
188
|
+
show();
|
|
189
|
+
}
|
|
190
|
+
document.dispatchEvent(new CustomEvent("erase", { detail: p })); // other tools' things (frames: unsent frames)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- steps ----
|
|
194
|
+
let steps = [], first = []; // steps: [{marks: [text], then: [click], shot: {path, url} | null, ready}]
|
|
195
|
+
let grabbed = null; // the screen when the last button went down (a click may end a step with it)
|
|
196
|
+
function bounds(pts) { // window px, with room around the drawing, inside the window
|
|
197
|
+
const w = pts.map(inWindow), pad = 60;
|
|
198
|
+
let x0 = Math.min(...w.map((p) => p.x)) - pad, x1 = Math.max(...w.map((p) => p.x)) + pad;
|
|
199
|
+
let y0 = Math.min(...w.map((p) => p.y)) - pad, y1 = Math.max(...w.map((p) => p.y)) + pad;
|
|
200
|
+
if (x1 - x0 < 480) { const c = (x0 + x1) / 2; x0 = c - 240; x1 = c + 240; }
|
|
201
|
+
if (y1 - y0 < 300) { const c = (y0 + y1) / 2; y0 = c - 150; y1 = c + 150; }
|
|
202
|
+
x0 = Math.max(0, x0); y0 = Math.max(0, y0); x1 = Math.min(innerWidth, x1); y1 = Math.min(innerHeight, y1);
|
|
203
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
204
|
+
}
|
|
205
|
+
function keep(full) { // the marks on the page become a step (full: the screen to crop its picture from)
|
|
206
|
+
const box = marks.length ? bounds(marks.flatMap((m) => m.points)) : { x: 0, y: 0, w: innerWidth, h: innerHeight };
|
|
207
|
+
const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
|
|
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/)
|
|
210
|
+
area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
|
|
211
|
+
step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
|
|
212
|
+
steps.push(step);
|
|
213
|
+
for (const m of marks) m.el.remove();
|
|
214
|
+
marks = [];
|
|
215
|
+
show();
|
|
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
|
+
});
|
|
224
|
+
function clicked(t, surface) { // a click, as words
|
|
225
|
+
const label = short(t.getAttribute("aria-label") || t.title || t.textContent || t.value || t.localName, 30);
|
|
226
|
+
const row = t.closest("tr, li");
|
|
227
|
+
const rowText = row && row !== t ? short((row.innerText || row.textContent).replace(t.innerText || t.textContent, ""), 30) : "";
|
|
228
|
+
return `clicked "${label}"${rowText ? ` on the "${rowText}" row` : ""}${surface ? ` (surface ${surface})` : ""}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- presses: this page and every App frame's page ----
|
|
232
|
+
// Text boxes draw too (a click still focuses them, a double click still selects a word): circling a notes box
|
|
233
|
+
// is a common thing to draw.
|
|
234
|
+
const OWN_DRAG = ".surface-label, .surface-resize, select, input[type='range'], [draggable='true'], .frame, .doodle-note";
|
|
235
|
+
const CLICKABLE = "button, a, summary, select, label, [onclick], [role='button'], [role='tab'], [role='checkbox'], input[type='checkbox'], input[type='radio']";
|
|
236
|
+
let press = null, line = null, swallow = false, over = null; // over: the pointer, in window px, while it is over the page
|
|
237
|
+
function hook(win, frameEl) {
|
|
238
|
+
const doc = win.document;
|
|
239
|
+
if (frameEl) {
|
|
240
|
+
if (docs.has(doc)) return;
|
|
241
|
+
docs.add(doc);
|
|
242
|
+
const s = doc.createElement("style");
|
|
243
|
+
s.textContent = CURSORS;
|
|
244
|
+
doc.head.append(s);
|
|
245
|
+
classes(doc.documentElement);
|
|
246
|
+
}
|
|
247
|
+
const offset = () => {
|
|
248
|
+
if (!frameEl) return { x: 0, y: 0 };
|
|
249
|
+
const r = frameEl.getBoundingClientRect();
|
|
250
|
+
return { x: r.left + frameEl.clientLeft, y: r.top + frameEl.clientTop };
|
|
251
|
+
};
|
|
252
|
+
const root = frameEl ? doc : area;
|
|
253
|
+
root.addEventListener("pointerdown", (e) => {
|
|
254
|
+
if (!on || e.button !== 0) return;
|
|
255
|
+
const o = offset();
|
|
256
|
+
if (erasing) { // the press is for the eraser, not for what's under it
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
press = { erase: true, win };
|
|
259
|
+
eraseAt(inMain(e.clientX + o.x, e.clientY + o.y));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
grabbed = marks.length ? frame() : null; // before the click acts
|
|
263
|
+
if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
|
|
264
|
+
if (texting) e.preventDefault(); // the press is for the words (or a line), not for what's under it
|
|
265
|
+
press = { x: e.clientX + o.x, y: e.clientY + o.y, id: e.pointerId, target: e.target, win, o, text: texting };
|
|
266
|
+
}, true);
|
|
267
|
+
win.addEventListener("pointermove", (e) => {
|
|
268
|
+
const o = offset(), x = e.clientX + o.x, y = e.clientY + o.y;
|
|
269
|
+
over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
|
|
270
|
+
if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
|
|
271
|
+
if (!press || press.win !== win || !drawing) return;
|
|
272
|
+
if (!line) {
|
|
273
|
+
if (Math.hypot(x - press.x, y - press.y) < 6) return; // not yet a line: may still be a click
|
|
274
|
+
line = { el: document.createElementNS(SVG, "g"), points: [inMain(press.x, press.y)] };
|
|
275
|
+
line.el.append(document.createElementNS(SVG, "path"));
|
|
276
|
+
ink.append(line.el);
|
|
277
|
+
marks.push(line);
|
|
278
|
+
show();
|
|
279
|
+
try { press.target.setPointerCapture(press.id); } catch {}
|
|
280
|
+
const active = press.win.document.activeElement; // a text box the press focused: typing now writes words, not into it
|
|
281
|
+
if (active?.matches?.("input, textarea, [contenteditable='true']")) active.blur();
|
|
282
|
+
}
|
|
283
|
+
win.getSelection().removeAllRanges(); // the first few px may have started a text selection
|
|
284
|
+
const p = inMain(x, y), last = line.points.at(-1);
|
|
285
|
+
if (Math.hypot(p.x - last.x, p.y - last.y) < 1.5) return;
|
|
286
|
+
line.points.push(p);
|
|
287
|
+
line.el.firstChild.setAttribute("d", smooth(line.points));
|
|
288
|
+
});
|
|
289
|
+
win.addEventListener("pointerup", () => {
|
|
290
|
+
if (press?.erase) { swallow = true; setTimeout(() => { swallow = false; }); } // the eraser's press isn't a click
|
|
291
|
+
if (press?.text && !line) { // Text: words where the press was (a press that drew a line was for the marker)
|
|
292
|
+
note(inMain(press.x, press.y), "");
|
|
293
|
+
swallow = true;
|
|
294
|
+
setTimeout(() => { swallow = false; });
|
|
295
|
+
}
|
|
296
|
+
if (line) {
|
|
297
|
+
if (line.points.length < 3) { marks.pop(); line.el.remove(); }
|
|
298
|
+
swallow = true; // the click that ends a line isn't a click
|
|
299
|
+
setTimeout(() => { swallow = false; });
|
|
300
|
+
show();
|
|
301
|
+
}
|
|
302
|
+
press = line = null;
|
|
303
|
+
});
|
|
304
|
+
root.addEventListener("click", (e) => {
|
|
305
|
+
if (swallow) { e.stopPropagation(); e.preventDefault(); swallow = false; return; }
|
|
306
|
+
const t = on && e.target.closest?.(CLICKABLE);
|
|
307
|
+
if (!t || t.closest(".doodle-note")) return;
|
|
308
|
+
if (marks.length) keep(grabbed); // the drawing so far, as the screen was when the button went down
|
|
309
|
+
(steps.length ? steps.at(-1).then : first).push(clicked(t, (frameEl || t).closest("[data-surface]")?.dataset.surface));
|
|
310
|
+
render();
|
|
311
|
+
}, true);
|
|
312
|
+
root.addEventListener("dragstart", (e) => { if (on) e.preventDefault(); }, true); // links and pictures don't get dragged away
|
|
313
|
+
doc.addEventListener("keydown", onKey);
|
|
314
|
+
}
|
|
315
|
+
hook(window, null);
|
|
316
|
+
const hookFrame = (fr) => {
|
|
317
|
+
const go = () => { try { if (fr.contentDocument?.readyState !== "loading") hook(fr.contentWindow, fr); } catch {} }; // other origins: not ours
|
|
318
|
+
fr.addEventListener("load", go);
|
|
319
|
+
go();
|
|
320
|
+
};
|
|
321
|
+
area.querySelectorAll("iframe").forEach(hookFrame);
|
|
322
|
+
new MutationObserver((list) => {
|
|
323
|
+
for (const r of list) for (const n of r.addedNodes) {
|
|
324
|
+
if (n.localName === "iframe") hookFrame(n);
|
|
325
|
+
else n.querySelectorAll?.("iframe").forEach(hookFrame);
|
|
326
|
+
}
|
|
327
|
+
}).observe(area, { childList: true, subtree: true });
|
|
328
|
+
|
|
329
|
+
function onKey(e) {
|
|
330
|
+
if (e.target.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
|
331
|
+
if (e.key === "Escape" && on) { allOff(); return; }
|
|
332
|
+
if (!on || erasing) return;
|
|
333
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
|
334
|
+
const m = marks.pop();
|
|
335
|
+
if (m) { m.el.remove(); show(); e.preventDefault(); }
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey && over) {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
note(inMain(over.x, over.y), e.key);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---- the strip in the message line ----
|
|
345
|
+
function render() {
|
|
346
|
+
strip.replaceChildren();
|
|
347
|
+
steps.forEach((s, i) => {
|
|
348
|
+
const chip = Object.assign(document.createElement("span"), { className: "doodle-chip", textContent: i + 1 });
|
|
349
|
+
const x = Object.assign(document.createElement("span"), { className: "x", textContent: "✕", title: "Drop this step" });
|
|
350
|
+
x.onclick = () => { peek.hidden = true; steps.splice(i, 1); render(); };
|
|
351
|
+
chip.append(x);
|
|
352
|
+
const words = s.words.map((w) => `"${w}"`).join(" ");
|
|
353
|
+
chip.title = s.shot ? "Click to see it big" : "No picture: the tab isn't shared";
|
|
354
|
+
chip.onmouseenter = () => { // a small preview above the chip
|
|
355
|
+
peek.replaceChildren();
|
|
356
|
+
const place = () => {
|
|
357
|
+
const r = chip.getBoundingClientRect(), p = peek.getBoundingClientRect();
|
|
358
|
+
Object.assign(peek.style, { left: `${Math.max(8, Math.min(innerWidth - p.width - 8, r.left))}px`, top: `${r.top - p.height - 10}px` });
|
|
359
|
+
};
|
|
360
|
+
if (s.shot?.url) peek.append(Object.assign(document.createElement("img"), { src: s.shot.url, onload: place }));
|
|
361
|
+
peek.append(words || (s.shot ? "" : "no picture (the tab isn't shared)"));
|
|
362
|
+
peek.hidden = false;
|
|
363
|
+
place();
|
|
364
|
+
};
|
|
365
|
+
chip.onmouseleave = () => { peek.hidden = true; };
|
|
366
|
+
chip.onclick = (e) => { // the picture big, over the page; a click or Esc closes it
|
|
367
|
+
if (e.target === x || !s.shot?.url) return;
|
|
368
|
+
peek.hidden = true;
|
|
369
|
+
big.replaceChildren(Object.assign(document.createElement("img"), { src: s.shot.url }),
|
|
370
|
+
Object.assign(document.createElement("div"), { textContent: `Step ${i + 1}${words ? ` · ${words}` : ""}` }));
|
|
371
|
+
big.hidden = false;
|
|
372
|
+
};
|
|
373
|
+
strip.append(chip);
|
|
374
|
+
for (const c of s.then) strip.append(Object.assign(document.createElement("span"), { className: "doodle-click", textContent: `→ ${c.replace(/^clicked "([^"]*)".*/, "$1")}`, title: c }));
|
|
375
|
+
});
|
|
376
|
+
if (marks.length) strip.append(Object.assign(document.createElement("span"), { className: "doodle-chip now", textContent: `${marks.length} mark${marks.length > 1 ? "s" : ""}` }));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- send: Enter in the message line takes the doodles along (chat_ui's own send is for plain words) ----
|
|
380
|
+
document.addEventListener("submit", async (e) => {
|
|
381
|
+
if (e.target !== form || (!steps.length && !marks.length)) return;
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
e.stopPropagation();
|
|
384
|
+
const text = input.value;
|
|
385
|
+
input.value = "";
|
|
386
|
+
if (marks.length) keep(frame());
|
|
387
|
+
else if (steps.at(-1).then.length) keep(frame()); // clicked after the last step: how the screen looks now
|
|
388
|
+
const sent = steps;
|
|
389
|
+
steps = [];
|
|
390
|
+
const before = first;
|
|
391
|
+
first = [];
|
|
392
|
+
render();
|
|
393
|
+
await Promise.all(sent.map((s) => s.ready));
|
|
394
|
+
page.send({ type: "doodle_send", to: document.getElementById("to")?.value, text, first: before,
|
|
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 })) });
|
|
396
|
+
allOff();
|
|
397
|
+
}, true);
|
|
398
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Doodles
|
|
2
|
+
The user draws on the page and clicks through it, then sends it with a few words: "[doodles on the UI]", with a
|
|
3
|
+
picture for each step.
|
|
4
|
+
- Read the steps in order, as a story; look closely at every picture: the drawing is the message.
|
|
5
|
+
- If it could mean two quite different things, propose one in a line and ask; otherwise do it.
|
|
6
|
+
Before you handle a doodle, read README.md.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Doodle: draw on the page, click through it, and send the whole sequence to an agent (port of the Python doodle
|
|
2
|
+
// plugin; browser.js is its doodle.js). browser.js adds a marker, Text and an eraser to the palette: while the marker
|
|
3
|
+
// is on, a drag draws, typing writes words where the pointer is, and a click works the page as usual. A click after
|
|
4
|
+
// drawing makes the drawing a step: a picture of the screen as the user saw it (from the tab, shared once: the
|
|
5
|
+
// screen extension), cropped to the drawing. Enter in the message line sends the steps and the words typed there,
|
|
6
|
+
// together, to the agent picked in the chat (else agent1), with the pictures attached to the message itself
|
|
7
|
+
// (swarm send's images), in step order.
|
|
8
|
+
// page message doodle_send {to, text, first: [click], steps: [{shot, area, drawn, words: [str], then: [click]}]}
|
|
9
|
+
// (shot: a path from /screen/shot, or null when the tab wasn't shared; area: the part of the main
|
|
10
|
+
// space the picture shows, main px; drawn: anything drawn on it; words: what the user typed on it,
|
|
11
|
+
// exactly; clicks are words browser.js wrote)
|
|
12
|
+
// The drawing itself goes only as the picture: lines aren't put into words (a guess at what a stroke means can be
|
|
13
|
+
// wrong, and the picture shows it).
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const host = (globalThis as any).chalky;
|
|
19
|
+
const s = (n: number) => (n === 1 ? "" : "s");
|
|
20
|
+
|
|
21
|
+
function message(msg: any) {
|
|
22
|
+
const steps: any[] = msg.steps ?? [];
|
|
23
|
+
const shots: string[] = steps.map((st) => st.shot).filter((p) => host.api.screen?.isShot(p));
|
|
24
|
+
const words = String(msg.text ?? "").trim();
|
|
25
|
+
const lines = [`[doodles on the UI] ${words || "(no words: the drawing says it)"}`,
|
|
26
|
+
`${steps.length} step${s(steps.length)}, ${shots.length} picture${s(shots.length)} attached in this order`
|
|
27
|
+
+ (shots.length === steps.length ? "" : " (the tab wasn't shared for the others: words only)") + ":"];
|
|
28
|
+
if (msg.first?.length) lines.push(` first: ${msg.first.join("; ")}`);
|
|
29
|
+
let n = 0;
|
|
30
|
+
steps.forEach((st, i) => {
|
|
31
|
+
const has = shots.includes(st.shot), a = st.area;
|
|
32
|
+
if (has) n++;
|
|
33
|
+
lines.push(`Step ${i + 1}` + (st.board ? ` on board ${st.board}` : "") + (has ? ` (picture ${n})` : " (no picture)")
|
|
34
|
+
+ (has && a ? `, showing x=${a.x} y=${a.y} w=${a.width} h=${a.height} of the page` : "")
|
|
35
|
+
+ (st.drawn ? "" : ": nothing drawn, how the screen looked after those clicks"));
|
|
36
|
+
for (const w of st.words ?? []) lines.push(` words: "${w}"`);
|
|
37
|
+
if (st.then?.length) lines.push(` then: ${st.then.join("; ")}`);
|
|
38
|
+
});
|
|
39
|
+
return { text: lines.join("\n"), shots };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
|
|
44
|
+
.replace("README.md", path.join(import.meta.dirname, "README.md"));
|
|
45
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.doodle = guide; });
|
|
46
|
+
|
|
47
|
+
host.pageHandlers.set("doodle", (msg: any) => {
|
|
48
|
+
const swarm = host.api.swarm;
|
|
49
|
+
if (msg.type !== "doodle_send" || !swarm) return;
|
|
50
|
+
const to = msg.to in swarm.status() ? msg.to : "agent1";
|
|
51
|
+
const { text, shots } = message(msg);
|
|
52
|
+
swarm.send("user", to, text, false, shots);
|
|
53
|
+
});
|
|
54
|
+
}
|