From 4fdfbe58fd447ae377acaf52d663ed445530a576 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:39 -0300 Subject: [PATCH 1/2] feat(screens): drafts carry the bot's cursor script Each draft served at /view gets a small script first, after the doctype so the page keeps its layout mode. Once the page is read it finds where the content ends (the newest text, or the newest element), scrolls so that spot stays in view, and posts the point, the part around it and the view size it measured in to the app. A frame from another origin may read the page before it knows its size, so the script waits for the resize. The file on disk is served untouched. Spec 22.3, 22.5, 22.6 and T3; tested with the real Claude Code. --- crates/botloftd/src/screens/cursor.js | 86 +++++++++++++++++++++++++++ crates/botloftd/src/screens/cursor.rs | 66 ++++++++++++++++++++ crates/botloftd/src/screens/mod.rs | 1 + crates/botloftd/src/screens/view.rs | 7 ++- crates/botloftd/tests/screens.rs | 15 ++++- docs/spec.md | 9 ++- 6 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 crates/botloftd/src/screens/cursor.js create mode 100644 crates/botloftd/src/screens/cursor.rs diff --git a/crates/botloftd/src/screens/cursor.js b/crates/botloftd/src/screens/cursor.js new file mode 100644 index 00000000..c099f64b --- /dev/null +++ b/crates/botloftd/src/screens/cursor.js @@ -0,0 +1,86 @@ +// The bot's cursor on a screen it is writing (spec 22.3). The daemon puts +// this first in every draft it serves. Once the page is read, it finds where +// the content ends right now (the end of the newest text, or the newest +// element), keeps that spot in view and tells the app where it is and which +// part of the page holds it, in the page's pixels, with the size of the view +// they are measured in. It changes nothing else. +(() => { + const SKIP = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "BR", "WBR"]); + const shown = (rect) => rect !== undefined && (rect.width > 0 || rect.height > 0); + + const end = () => { + const body = document.body; + if (!body) { + return null; + } + const walk = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + const nodes = []; + for (let node = walk.nextNode(); node; node = walk.nextNode()) { + nodes.push(node); + } + for (let at = nodes.length - 1; at >= 0; at -= 1) { + const node = nodes[at]; + const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!element || element.closest("script, style, template, noscript") || SKIP.has(element.tagName)) { + continue; + } + if (node.nodeType === Node.TEXT_NODE) { + if (!node.data.trim()) { + continue; + } + const range = document.createRange(); + range.selectNodeContents(node); + const lines = range.getClientRects(); + const last = lines[lines.length - 1]; + if (shown(last)) { + return { element, x: last.right, y: last.top + last.height / 2 }; + } + } else { + const box = element.getBoundingClientRect(); + if (shown(box)) { + return { element, x: box.left + box.width / 2, y: box.top + box.height / 2 }; + } + } + } + return null; + }; + + // The part of the page around the spot: the nearest element that is not + // inline text, short of the whole page. + const part = (element) => { + let current = element; + while (current.parentElement && getComputedStyle(current).display === "inline") { + current = current.parentElement; + } + return current === document.body || current === document.documentElement ? null : current; + }; + + const follow = () => { + // A frame from another origin runs in its own process and learns its + // size a moment after the page is read: measure once it has one. + if (innerWidth === 0 || innerHeight === 0) { + addEventListener("resize", follow, { once: true }); + return; + } + let spot = end(); + if (spot && (spot.y > innerHeight * 0.85 || spot.y < 0)) { + scrollTo({ top: scrollY + spot.y - innerHeight * 0.6, behavior: "instant" }); + spot = end(); + } + const around = spot && part(spot.element); + const box = around?.getBoundingClientRect(); + parent.postMessage( + { + botloft: "cursor", + view: { width: innerWidth, height: innerHeight }, + point: spot ? { x: spot.x, y: spot.y } : null, + box: box ? { x: box.left, y: box.top, width: box.width, height: box.height } : null, + }, + "*", + ); + }; + + addEventListener("DOMContentLoaded", follow); + // Images and fonts move things once they arrive. + addEventListener("load", follow); +})(); diff --git a/crates/botloftd/src/screens/cursor.rs b/crates/botloftd/src/screens/cursor.rs new file mode 100644 index 00000000..5105a835 --- /dev/null +++ b/crates/botloftd/src/screens/cursor.rs @@ -0,0 +1,66 @@ +//! The bot's cursor on a screen it writes (spec 22.3): each draft served +//! carries a small script that tells the app where the page ends now. + +const SCRIPT: &str = include_str!("cursor.js"); +const DOCTYPE: &str = " String { + let script = format!(""); + let rest = html.trim_start_matches(['\u{feff}', ' ', '\t', '\r', '\n']); + let start = html.len() - rest.len(); + let head = rest.get(..DOCTYPE.len()); + if head.is_some_and(|head| head.eq_ignore_ascii_case(DOCTYPE)) { + return match rest.find('>') { + Some(close) => { + let at = start + close + 1; + format!("{}{script}{}", &html[..at], &html[at..]) + } + None => html.to_owned(), + }; + } + let begun = rest.len() < DOCTYPE.len() + && !rest.is_empty() + && DOCTYPE.starts_with(&rest.to_ascii_lowercase()); + if begun { + return html.to_owned(); + } + format!("{script}{html}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const TAG: &str = "\n
é
"); + assert!(marked.starts_with("\u{feff} "; + struct Page { status: u16, head: String, @@ -103,7 +106,9 @@ async fn a_screen_is_built_live_and_then_read_from_disk() { let page = get(t.addr, &url, None).await; assert_eq!(page.status, 200); - assert_eq!(page.body, "Fresh \"bread\"
"); + assert!( + page.body + .ends_with("Fresh \"bread\"
"), + "{}", + page.body + ); // Not on disk yet: listed from the draft, being written. let screens = app @@ -151,6 +161,7 @@ async fn a_screen_is_built_live_and_then_read_from_disk() { "{}", page.body ); + assert!(!page.body.contains(CURSOR), "the file is served as it is"); let screens = app .call("screens.list", json!({ "botId": bot["id"] })) diff --git a/docs/spec.md b/docs/spec.md index 85b65544..6516f9c4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -782,6 +782,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | | Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito | +| Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito | | Telas no WebView2 do app | 22.5 | Não visto: a CSP do Tauri com `frame-src http://127.0.0.1:*` e as telas carregando no `iframe` | manual (PR): `pnpm tauri dev`, pedir uma página HTML a um bot e ver a área de design | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR. @@ -1096,7 +1097,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro ## 22. Telas -Status: **T1 e T2 implementados** (22.8). É o N4 da seção 21. +Status: **T1, T2 e T3 implementados** (22.8). É o N4 da seção 21. ### 22.1 O que é @@ -1111,7 +1112,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com - `GET /view/