// Thin Ice: the page controller. Wires the lake, the agent loop and the
// three ways to play (Watch, You're the model, Live) to the panels.
import { createLake, SYSTEM_PROMPT, TOOLS, TASK, cellName } from "./world.js";
import { agentLoop } from "./loop.js";
import { scriptedTurn, resetScriptedIds } from "./scripted.js";
import LOOP_SOURCE from "./loop-source.js";
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
const WATCH_SEEDS = [7, 6, 14, 22, 32, 8, 27, 49, 11, 4];
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const params = new URLSearchParams(location.search);
class Cancelled extends Error {}
const CONCEPTS = {
tools: ["Tool use", "The model asks for a tool with a tool_use block. Your code runs it."],
parallel: ["Parallel calls", "Independent tool calls can come in one turn. All results go back together."],
errors: ["Error recovery", "A failed tool returns is_error: true, and the model adjusts."],
history: ["Growing context", "Every request resends the whole conversation. That's the model's only memory."],
stop: ["Stop reason", "stop_reason tells your loop whether to keep going."],
};
const LINES = {
request: "await callModel(",
response: 'messages.push({ role: "assistant"',
check: 'if (response.stop_reason !== "tool_use")',
tool: "const out = runTool(",
results: 'messages.push({ role: "user", content: results })',
done: "return { stop: response.stop_reason",
};
const state = {
mode: "watch",
seedIndex: 0,
lake: null,
run: 0,
paused: false,
stepWaiter: null,
speed: 1,
fog: false,
concepts: new Set(),
bars: [],
toolCells: new Map(), // tool_use id -> cell, for hover links
started: false,
messages: [],
abort: null,
};
let scene;
const ui = {};
// ---------------------------------------------------------------------------
// Boot
export async function boot(root) {
Object.assign(ui, {
root,
stage: $("#stage", root), caption: $("#capText", root), capKind: $("#capKind", root),
turn: $("#turnChip", root), stop: $("#stopChip", root), eye: $("#eyeToggle", root),
msgs: $("#msgs", root), msgCount: $("#msgCount", root), code: $("#loopCode", root), req: $("#lastReq", root),
toolsPane: $("#toolsJson", root), bars: $("#bars", root), tokNote: $("#tokNote", root), concepts: $("#concepts", root),
play: $("#playBtn", root), step: $("#stepBtn", root), speedBtn: $("#speedBtn", root), newBtn: $("#newBtn", root),
composer: $("#composer", root), live: $("#livebox", root), transport: $(".transport", root),
});
root.classList.add("js");
// On phones the caption sits under the lake instead of over it.
const capBox = ui.caption.parentElement, narrow = matchMedia("(max-width: 900px)");
const placeCaption = () => (narrow.matches ? ui.stage.after(capBox) : ui.stage.append(capBox));
narrow.addEventListener("change", placeCaption);
placeCaption();
const lite = params.has("lite") || navigator.connection?.saveData;
let use3d = !lite;
if (use3d) {
const { LakeScene, webglAvailable } = await import("./scene.js");
if (webglAvailable()) {
try { scene = new LakeScene(ui.stage, { reducedMotion }); } catch { use3d = false; }
} else use3d = false;
}
if (!scene) {
const { FlatScene } = await import("./scene2d.js");
scene = new FlatScene(ui.stage, { reducedMotion });
}
root.classList.toggle("is-flat", !use3d);
root.classList.add("ready");
renderCode();
renderTools();
renderConcepts();
bindControls();
bindHover();
setMode("watch", { autostart: false });
// Start the demo when the lab scrolls into view.
new IntersectionObserver(([e], obs) => {
if (e.isIntersecting && !state.started) { state.started = true; obs.disconnect(); startRun(); }
}, { threshold: 0.35 }).observe(ui.stage);
}
// ---------------------------------------------------------------------------
// Running
function freshLake() {
const seed = state.mode === "watch" ? WATCH_SEEDS[state.seedIndex % WATCH_SEEDS.length] : 1000 + Math.floor(Math.random() * 9000);
state.lake = createLake(seed);
scene.reset(state.lake);
syncScene();
}
function syncScene() {
const fog = state.mode === "model" ? !state.revealed : state.fog;
scene.sync(state.lake, { fog });
}
function resetPanels() {
ui.msgs.innerHTML = "";
state.messages = [];
state.toolCells.clear();
state.bars = [];
ui.bars.innerHTML = "";
ui.tokNote.textContent = "";
ui.req.innerHTML = "";
state.concepts.clear();
renderConcepts();
setTurn(0);
setStop("ready");
highlight(null);
}
function cancelRun() {
state.run++;
state.abort?.abort();
state.abort = null;
if (state.stepWaiter) { state.stepWaiter(); state.stepWaiter = null; }
if (state.humanWaiter) { state.humanWaiter.reject(new Cancelled()); state.humanWaiter = null; }
delete ui.root.dataset.running;
delete ui.root.dataset.waiting;
}
async function startRun() {
cancelRun();
const run = state.run;
state.revealed = false;
resetScriptedIds();
freshLake();
resetPanels();
ui.root.dataset.running = "1";
let callModel;
if (state.mode === "watch") {
callModel = async ({ messages }) => {
await pace(run, 0.7);
const r = scriptedTurn(messages);
return { ...r, usage: null };
};
} else if (state.mode === "model") {
callModel = ({ messages }) => humanTurn(run, messages);
} else {
const key = $("#apiKey").value.trim();
const model = $("#modelSel").value;
state.abort = new AbortController();
const { makeClaude } = await import("./claude.js");
callModel = await makeClaude({ apiKey: key, model, signal: state.abort.signal, onParams: (p) => showRequest(p) });
}
try {
await agentLoop({
callModel, lake: state.lake, maxTurns: 30,
on: (ev, d) => {
state.loopMessages = d.messages ?? state.loopMessages; // the real array, for the JSON view
if (ui.msgs.classList.contains("raw")) refreshRaw();
return onEvent(run, ev, d);
},
});
} catch (err) {
if (err instanceof Cancelled || run !== state.run) return;
if (err?.name === "AbortError" || state.abort?.signal.aborted) return;
const { describeError } = await import("./claude.js");
const msg = describeError(err);
$("#liveErr").textContent = msg;
setStop("error");
caption("Error", esc(msg));
highlight(null);
} finally {
if (run === state.run) {
delete ui.root.dataset.running;
updateTransport();
if (state.mode === "live") $("#runLive").disabled = false;
}
}
}
// Wait between steps, honoring pause, step and speed. Throws if the run was replaced.
async function pace(run, seconds) {
if (run !== state.run) throw new Cancelled();
if (state.mode === "watch") {
if (state.paused) await new Promise((r) => (state.stepWaiter = r));
else await sleep((seconds * 1000) / state.speed);
} else {
await sleep(seconds * 300);
}
if (run !== state.run) throw new Cancelled();
}
async function onEvent(run, ev, d) {
if (run !== state.run) throw new Cancelled();
const live = state.mode === "live";
switch (ev) {
case "start": {
addMessage(d.messages[0]);
caption("Start", `The run starts with one user message: the task. The model also gets a system prompt and ${TOOLS.length} tool definitions. That's everything it will ever know about this lake.`);
await pace(run, 1.8);
break;
}
case "request": {
highlight("request");
setTurn(d.turn);
const est = estimateTokens(d.messages);
addBar({ est, live });
if (d.turn >= 3) concept("history");
caption(`Turn ${d.turn}`, d.turn === 1
? "The loop sends the task to the model and waits for an answer."
: `The whole conversation, all ${d.messages.length} messages, goes to the model again. It doesn't remember earlier calls. The history is its memory.`);
if (live) ui.root.dataset.waiting = "1";
if (state.mode !== "model") await pace(run, 0.5);
if (state.mode === "watch") showRequest(previewParams("scripted-stand-in", d.messages));
break;
}
case "response": {
delete ui.root.dataset.waiting;
highlight("response");
const r = d.response;
if (r.usage) updateBarUsage(r.usage);
addMessage({ role: "assistant", content: r.content });
const uses = r.content.filter((b) => b.type === "tool_use");
if (uses.length) concept("tools");
if (uses.length > 1) concept("parallel");
if (uses.length > 1) {
const names = uses.map((u) => `${u.name}`);
const list = names.length === 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
caption("Parallel", `The model asked for ${uses.length} tools in one turn: ${list}. They don't depend on each other, so it asked for ${uses.length === 2 ? "both" : "all of them"} at once.`);
}
else if (uses.length) caption("tool_use", `The model can't touch the lake. It answered with a tool_use block asking your code to run ${uses[0].name}.`);
await pace(run, uses.length ? 1.4 : 0.6);
highlight("check");
setStop(r.stop_reason);
await pace(run, 0.5);
break;
}
case "tool": {
highlight("tool");
const ev2 = d.out.event;
const cell = ev2.cell || ev2.to;
if (cell) state.toolCells.set(d.block.id, cell);
addResult(d.block, d.out);
await scene.play(ev2, state.mode === "watch" ? state.speed : 1.4);
syncScene();
if (d.out.isError) {
concept("errors");
caption("is_error", `The tool refused. The error goes back as a tool_result with is_error: true, and the model gets a chance to recover. The safety rule lives in the tool, not only in the prompt.`);
await pace(run, 1.6);
} else if (ev2.type === "drill" && ev2.inches < 4) {
caption("Thin ice", `${cellName(...ev2.cell)} measured ${ev2.inches === 0 ? "open water" : `${ev2.inches} inches`}. The model reads only that number, and has to steer around it.`);
await pace(run, 1.2);
}
break;
}
case "results": {
highlight("results");
finishResults();
caption("tool_result", d.results.length > 1
? `Your code sends all ${d.results.length} results back in one user message, each matched to its call by tool_use_id.`
: "Your code sends the result back as a user message, matched to the call by tool_use_id. Then the loop goes around again.");
await pace(run, 0.9);
break;
}
case "done": {
highlight("done");
concept("stop");
setStop(d.stop);
const requests = d.messages.filter((m) => m.role === "assistant").length;
const calls = d.messages.flatMap((m) => (Array.isArray(m.content) ? m.content : [])).filter((b) => b.type === "tool_use").length;
const why = { end_turn: "The model stopped asking for tools", max_tokens: "The model ran out of output tokens", refusal: "The model declined", max_turns: "The loop hit its 30 turn safety cap" }[d.stop] || `The run stopped (${d.stop})`;
caption("Done", `stop_reason: ${esc(d.stop)}. ${why}, so the loop ends. ${requests} requests, ${calls} tool calls${state.lake.shanty ? ", and one shanty on the ice." : "."}`);
if (state.mode === "model") {
state.revealed = true;
syncScene();
}
ui.composer.hidden = true;
break;
}
}
}
// ---------------------------------------------------------------------------
// You're the model
function humanTurn(run, messages) {
return new Promise((resolve, reject) => {
if (run !== state.run) return reject(new Cancelled());
state.humanWaiter = { resolve, reject };
state.draft = [];
renderDraft();
$("#cmpText").value = "";
ui.composer.hidden = false;
ui.composer.classList.remove("in");
requestAnimationFrame(() => ui.composer.classList.add("in"));
caption("Your turn", messages.length === 1
? "You're the model now. You can't see the lake: it's fog, because a model only gets text. Read the task, pick one or more tools, and send your turn."
: "Read the latest tool results in Messages. Then pick your next tool calls. Everything you know came from that text.");
setStop("your turn");
});
}
let youId = 0;
function sendHumanTurn(finish) {
const w = state.humanWaiter;
if (!w) return;
const note = $("#cmpText").value.trim();
const content = [];
if (note || finish) content.push({ type: "text", text: note || "Done." });
if (!finish) {
if (!state.draft.length) { flash($("#draft")); return; }
for (const d of state.draft) content.push({ type: "tool_use", id: `toolu_you_${String(++youId).padStart(3, "0")}`, name: d.name, input: d.input });
}
state.humanWaiter = null;
ui.composer.hidden = true;
w.resolve({ content, stop_reason: finish ? "end_turn" : "tool_use", usage: null });
}
function renderDraft() {
const list = $("#draft");
list.innerHTML = state.draft.length
? state.draft.map((d, i) => `
${esc(JSON.stringify(d.input))} ${esc(JSON.stringify(b.input))}${esc(b.id)}${esc(body)}${esc(r.tool_use_id)}${esc(JSON.stringify(summary, null, 2))}`;
}
function renderConcepts() {
ui.concepts.innerHTML = Object.entries(CONCEPTS).map(([k, [name, tip]]) =>
`${name}`).join("") +
`${state.concepts.size}/5`;
}
function concept(k) {
if (state.concepts.has(k)) return;
state.concepts.add(k);
renderConcepts();
$(`.concept[data-k="${k}"]`, ui.concepts)?.classList.add("pop");
}
// Token bars: one per request, so you can watch the history grow.
function estimateTokens(messages) {
return Math.round(JSON.stringify({ system: SYSTEM_PROMPT, tools: TOOLS, messages }).length / 3.6);
}
function addBar({ est, live }) {
const bar = { est, el: document.createElement("span") };
bar.el.className = "bar" + (live ? " wait" : "");
bar.el.innerHTML = ``;
ui.bars.appendChild(bar.el);
state.bars.push(bar);
drawBars();
}
function updateBarUsage(u) {
const bar = state.bars.at(-1);
if (!bar) return;
bar.live = true;
bar.fresh = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
bar.cached = u.cache_read_input_tokens || 0;
bar.el.classList.remove("wait");
drawBars();
}
function drawBars() {
const totals = state.bars.map((b) => (b.live ? b.fresh + b.cached : b.est));
const max = Math.max(4000, ...totals);
state.bars.forEach((b, i) => {
const total = totals[i];
b.el.style.height = `${(total / max) * 100}%`;
b.el.querySelector(".cached").style.height = b.live && total ? `${(b.cached / total) * 100}%` : "0%";
b.el.title = b.live ? `${total.toLocaleString()} input tokens, ${b.cached.toLocaleString()} read from cache` : `About ${total.toLocaleString()} tokens`;
});
const last = state.bars.at(-1);
if (!last) { ui.tokNote.textContent = ""; return; }
ui.tokNote.innerHTML = last.live
? `${(last.fresh + last.cached).toLocaleString()} in the last request, ${last.cached.toLocaleString()} cached`
: `about ${last.est.toLocaleString()} in the last request`;
}
// ---------------------------------------------------------------------------
// Controls
function setMode(mode, { autostart = true } = {}) {
cancelRun();
state.mode = mode;
ui.root.dataset.mode = mode;
$$("[data-mode]", ui.root).forEach((b) => b.setAttribute("aria-selected", String(b.dataset.mode === mode)));
ui.composer.hidden = true;
ui.live.hidden = mode !== "live";
ui.eye.disabled = mode === "model";
ui.eye.setAttribute("aria-pressed", String(mode === "model" || state.fog));
state.paused = false;
updateTransport();
resetScriptedIds();
freshLake();
resetPanels();
if (mode === "watch") {
if (autostart) startRun();
else caption("Watch", "A scripted agent is about to guide the sled across the lake. Watch the messages and the loop code as it goes.");
} else if (mode === "model") {
if (autostart) startRun();
} else {
addMessage({ role: "user", content: TASK });
caption("Live", "Paste your own Anthropic API key and run the real Claude on a fresh lake. Every request and response shows up here as it happens.");
$("#runLive").disabled = false;
}
}
function updateTransport() {
const watch = state.mode === "watch";
ui.play.hidden = ui.step.hidden = ui.speedBtn.hidden = !watch;
ui.play.textContent = state.paused ? "Play" : "Pause";
ui.play.setAttribute("aria-pressed", String(state.paused));
ui.step.disabled = !state.paused;
ui.newBtn.textContent = state.mode === "live" ? "New lake" : "Restart on a new lake";
}
function bindControls() {
$$("[data-mode]", ui.root).forEach((b) => b.addEventListener("click", () => { if (state.mode !== b.dataset.mode) { state.started = true; setMode(b.dataset.mode); } }));
$$("[data-tab]", ui.root).forEach((b) => b.addEventListener("click", () => {
$$("[data-tab]", ui.root).forEach((x) => x.setAttribute("aria-selected", String(x === b)));
$$("[data-pane]", ui.root).forEach((p) => (p.hidden = p.dataset.pane !== b.dataset.tab));
if (b.dataset.tab === "code") highlight($(".ln.on", ui.code)?.dataset.phase);
}));
ui.play.addEventListener("click", () => {
state.paused = !state.paused;
if (!state.paused && state.stepWaiter) { state.stepWaiter(); state.stepWaiter = null; }
if (!state.started) { state.started = true; startRun(); }
updateTransport();
});
ui.step.addEventListener("click", () => { if (state.stepWaiter) { const w = state.stepWaiter; state.stepWaiter = null; w(); } });
ui.speedBtn.addEventListener("click", () => {
state.speed = state.speed === 1 ? 2 : state.speed === 2 ? 4 : 1;
ui.speedBtn.textContent = `${state.speed}x`;
});
ui.newBtn.addEventListener("click", () => {
state.seedIndex++;
state.started = true;
if (state.mode === "live") { cancelRun(); freshLake(); resetPanels(); addMessage({ role: "user", content: TASK }); $("#runLive").disabled = false; }
else startRun();
});
ui.eye.addEventListener("click", () => {
state.fog = !state.fog;
ui.eye.setAttribute("aria-pressed", String(state.fog));
syncScene();
});
$("#jsonToggle", ui.root).addEventListener("click", (e) => {
const on = ui.msgs.classList.toggle("raw");
e.currentTarget.setAttribute("aria-pressed", String(on));
$("#rawJson").hidden = !on;
if (on) refreshRaw();
});
// Composer.
ui.composer.addEventListener("click", (e) => {
const t = e.target.closest("button");
if (!t) return;
if (t.dataset.tool) {
const input = t.dataset.dir ? { direction: t.dataset.dir } : {};
if (state.draft.length >= 4) { flash($("#draft")); return; }
state.draft.push({ name: t.dataset.tool, input });
renderDraft();
} else if (t.dataset.rm !== undefined) {
state.draft.splice(Number(t.dataset.rm), 1);
renderDraft();
} else if (t.id === "sendTurn") sendHumanTurn(false);
else if (t.id === "finishTurn") sendHumanTurn(true);
});
// Live.
$("#runLive").addEventListener("click", () => {
const key = $("#apiKey").value.trim();
$("#liveErr").textContent = "";
if (!/^sk-ant-/.test(key)) { $("#liveErr").textContent = "Paste an Anthropic API key. It starts with sk-ant-."; return; }
$("#runLive").disabled = true;
state.started = true;
startRun();
});
$("#stopLive").addEventListener("click", () => { cancelRun(); delete ui.root.dataset.running; delete ui.root.dataset.waiting; $("#runLive").disabled = false; setStop("stopped"); caption("Stopped", "You stopped the run. Nothing else will be sent."); });
}
// The raw transcript, as the loop holds it (thinking signatures shortened).
function rawMessages() {
const scrub = (b) => (b && b.signature ? { ...b, signature: b.signature.slice(0, 16) + "..." } : b);
return state.loopMessages?.map((m) => (Array.isArray(m.content) ? { ...m, content: m.content.map(scrub) } : m)) ?? [];
}
function refreshRaw() { $("#rawJson").textContent = JSON.stringify(rawMessages(), null, 2); }
function bindHover() {
// Hovering a tool block lights up its partner and the cell it touched.
ui.msgs.addEventListener("pointerover", (e) => {
const b = e.target.closest("[data-id]");
$$(".blk.link", ui.msgs).forEach((x) => x.classList.remove("link"));
if (!b) { scene.setHover(null); return; }
$$(`[data-id="${CSS.escape(b.dataset.id)}"]`, ui.msgs).forEach((x) => x.classList.add("link"));
scene.setHover(state.toolCells.get(b.dataset.id) || null);
});
ui.msgs.addEventListener("pointerleave", () => { scene.setHover(null); $$(".blk.link", ui.msgs).forEach((x) => x.classList.remove("link")); });
ui.stage.addEventListener("pointermove", (e) => {
if (e.pointerType !== "mouse") return;
const cell = scene.pick(e.clientX, e.clientY);
scene.setHover(cell);
ui.stage.title = cell ? cellName(...cell) : "";
});
ui.stage.addEventListener("pointerleave", () => scene.setHover(null));
}
function flash(el) { el.classList.remove("shake"); void el.offsetWidth; el.classList.add("shake"); }