Skip to content

Commit 4fdfbe5

Browse files
committed
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.
1 parent ea4917f commit 4fdfbe5

6 files changed

Lines changed: 177 additions & 7 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// The bot's cursor on a screen it is writing (spec 22.3). The daemon puts
2+
// this first in every draft it serves. Once the page is read, it finds where
3+
// the content ends right now (the end of the newest text, or the newest
4+
// element), keeps that spot in view and tells the app where it is and which
5+
// part of the page holds it, in the page's pixels, with the size of the view
6+
// they are measured in. It changes nothing else.
7+
(() => {
8+
const SKIP = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "BR", "WBR"]);
9+
const shown = (rect) => rect !== undefined && (rect.width > 0 || rect.height > 0);
10+
11+
const end = () => {
12+
const body = document.body;
13+
if (!body) {
14+
return null;
15+
}
16+
const walk = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
17+
const nodes = [];
18+
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
19+
nodes.push(node);
20+
}
21+
for (let at = nodes.length - 1; at >= 0; at -= 1) {
22+
const node = nodes[at];
23+
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
24+
if (!element || element.closest("script, style, template, noscript") || SKIP.has(element.tagName)) {
25+
continue;
26+
}
27+
if (node.nodeType === Node.TEXT_NODE) {
28+
if (!node.data.trim()) {
29+
continue;
30+
}
31+
const range = document.createRange();
32+
range.selectNodeContents(node);
33+
const lines = range.getClientRects();
34+
const last = lines[lines.length - 1];
35+
if (shown(last)) {
36+
return { element, x: last.right, y: last.top + last.height / 2 };
37+
}
38+
} else {
39+
const box = element.getBoundingClientRect();
40+
if (shown(box)) {
41+
return { element, x: box.left + box.width / 2, y: box.top + box.height / 2 };
42+
}
43+
}
44+
}
45+
return null;
46+
};
47+
48+
// The part of the page around the spot: the nearest element that is not
49+
// inline text, short of the whole page.
50+
const part = (element) => {
51+
let current = element;
52+
while (current.parentElement && getComputedStyle(current).display === "inline") {
53+
current = current.parentElement;
54+
}
55+
return current === document.body || current === document.documentElement ? null : current;
56+
};
57+
58+
const follow = () => {
59+
// A frame from another origin runs in its own process and learns its
60+
// size a moment after the page is read: measure once it has one.
61+
if (innerWidth === 0 || innerHeight === 0) {
62+
addEventListener("resize", follow, { once: true });
63+
return;
64+
}
65+
let spot = end();
66+
if (spot && (spot.y > innerHeight * 0.85 || spot.y < 0)) {
67+
scrollTo({ top: scrollY + spot.y - innerHeight * 0.6, behavior: "instant" });
68+
spot = end();
69+
}
70+
const around = spot && part(spot.element);
71+
const box = around?.getBoundingClientRect();
72+
parent.postMessage(
73+
{
74+
botloft: "cursor",
75+
view: { width: innerWidth, height: innerHeight },
76+
point: spot ? { x: spot.x, y: spot.y } : null,
77+
box: box ? { x: box.left, y: box.top, width: box.width, height: box.height } : null,
78+
},
79+
"*",
80+
);
81+
};
82+
83+
addEventListener("DOMContentLoaded", follow);
84+
// Images and fonts move things once they arrive.
85+
addEventListener("load", follow);
86+
})();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! The bot's cursor on a screen it writes (spec 22.3): each draft served
2+
//! carries a small script that tells the app where the page ends now.
3+
4+
const SCRIPT: &str = include_str!("cursor.js");
5+
const DOCTYPE: &str = "<!doctype";
6+
7+
/// `html` with the cursor script first: right after the doctype, which has
8+
/// to stay first for the page to keep its layout mode, or at the very top.
9+
/// A doctype still being written gets no script this time.
10+
pub(super) fn with_cursor(html: &str) -> String {
11+
let script = format!("<script data-botloft-cursor>{SCRIPT}</script>");
12+
let rest = html.trim_start_matches(['\u{feff}', ' ', '\t', '\r', '\n']);
13+
let start = html.len() - rest.len();
14+
let head = rest.get(..DOCTYPE.len());
15+
if head.is_some_and(|head| head.eq_ignore_ascii_case(DOCTYPE)) {
16+
return match rest.find('>') {
17+
Some(close) => {
18+
let at = start + close + 1;
19+
format!("{}{script}{}", &html[..at], &html[at..])
20+
}
21+
None => html.to_owned(),
22+
};
23+
}
24+
let begun = rest.len() < DOCTYPE.len()
25+
&& !rest.is_empty()
26+
&& DOCTYPE.starts_with(&rest.to_ascii_lowercase());
27+
if begun {
28+
return html.to_owned();
29+
}
30+
format!("{script}{html}")
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use super::*;
36+
37+
const TAG: &str = "<script data-botloft-cursor>";
38+
39+
#[test]
40+
fn the_script_goes_after_the_doctype_or_first() {
41+
let page = with_cursor("<!DOCTYPE html>\n<h1>Bak");
42+
assert!(page.starts_with("<!DOCTYPE html><script data-botloft-cursor>"));
43+
assert!(page.ends_with("</script>\n<h1>Bak"));
44+
45+
let bare = with_cursor("<h1>Bak");
46+
assert!(bare.starts_with(TAG));
47+
assert!(bare.ends_with("</script><h1>Bak"));
48+
49+
let marked = with_cursor("\u{feff} <!doctype html><p>é</p>");
50+
assert!(marked.starts_with("\u{feff} <!doctype html><script"));
51+
}
52+
53+
#[test]
54+
fn a_doctype_being_written_waits() {
55+
assert_eq!(with_cursor("<!doc"), "<!doc");
56+
assert_eq!(with_cursor("<!DOCTYPE htm"), "<!DOCTYPE htm");
57+
assert_eq!(with_cursor("<"), "<");
58+
assert!(with_cursor("<p>").starts_with(TAG));
59+
assert!(with_cursor("").starts_with(TAG));
60+
}
61+
62+
#[test]
63+
fn the_script_never_closes_early() {
64+
assert!(!SCRIPT.contains("</script"));
65+
}
66+
}

‎crates/botloftd/src/screens/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! and the drafts of the HTML files bots are writing right now, read from
33
//! the tool input Claude Code streams while the model writes it.
44
5+
mod cursor;
56
mod partial;
67
mod place;
78
mod view;

‎crates/botloftd/src/screens/view.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use axum::response::{IntoResponse, Response};
1010
use tracing::debug;
1111

1212
use super::Place;
13+
use super::cursor::with_cursor;
1314
use super::place::{Root, roots};
1415
use crate::service::files::media_type;
1516
use crate::state::Daemon;
@@ -61,8 +62,8 @@ pub async fn handle(
6162
response
6263
}
6364

64-
/// The bytes and type at a `/view` address: the draft being written, or
65-
/// the file, if it is inside the folder.
65+
/// The bytes and type at a `/view` address: the draft being written, with
66+
/// the bot's cursor, or the file, if it is inside the folder.
6667
async fn find(
6768
daemon: &Daemon,
6869
key: &str,
@@ -73,7 +74,7 @@ async fn find(
7374
let place = Place::from_request(root, rel)?;
7475
let media = media_type(&place.rel);
7576
if let Some(draft) = daemon.screens.draft(&bot, &place) {
76-
return Some((draft.into_bytes(), media));
77+
return Some((with_cursor(&draft).into_bytes(), media));
7778
}
7879
let (work, workspace) = roots(daemon, &bot)?;
7980
let folder = match place.root {

‎crates/botloftd/tests/screens.rs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ use serde_json::{Value, json};
1313
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1414
use tokio::net::TcpStream;
1515

16+
/// How a draft served with the bot's cursor starts (spec 22.3).
17+
const CURSOR: &str = "<script data-botloft-cursor>";
18+
1619
struct Page {
1720
status: u16,
1821
head: String,
@@ -103,7 +106,9 @@ async fn a_screen_is_built_live_and_then_read_from_disk() {
103106

104107
let page = get(t.addr, &url, None).await;
105108
assert_eq!(page.status, 200);
106-
assert_eq!(page.body, "<h1>Bak");
109+
// The bot's cursor comes first, then what the bot wrote so far.
110+
assert!(page.body.starts_with(CURSOR), "{}", page.body);
111+
assert!(page.body.ends_with("</script><h1>Bak"), "{}", page.body);
107112
assert!(
108113
page.head
109114
.contains("content-security-policy: sandbox allow-scripts")
@@ -118,7 +123,12 @@ async fn a_screen_is_built_live_and_then_read_from_disk() {
118123
let last = draft(&mut app).await;
119124
assert!(last["rev"].as_u64() > first["rev"].as_u64());
120125
let page = get(t.addr, last["url"].as_str().expect("url"), None).await;
121-
assert_eq!(page.body, "<h1>Bakery</h1>\n<p>Fresh \"bread\"</p>");
126+
assert!(
127+
page.body
128+
.ends_with("</script><h1>Bakery</h1>\n<p>Fresh \"bread\"</p>"),
129+
"{}",
130+
page.body
131+
);
122132

123133
// Not on disk yet: listed from the draft, being written.
124134
let screens = app
@@ -151,6 +161,7 @@ async fn a_screen_is_built_live_and_then_read_from_disk() {
151161
"{}",
152162
page.body
153163
);
164+
assert!(!page.body.contains(CURSOR), "the file is served as it is");
154165

155166
let screens = app
156167
.call("screens.list", json!({ "botId": bot["id"] }))

‎docs/spec.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co
782782
| 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 |
783783
| 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 |
784784
| 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 |
785+
| 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 |
785786
| 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 |
786787

787788
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
10961097

10971098
## 22. Telas
10981099

1099-
Status: **T1 e T2 implementados** (22.8). É o N4 da seção 21.
1100+
Status: **T1, T2 e T3 implementados** (22.8). É o N4 da seção 21.
11001101

11011102
### 22.1 O que é
11021103

@@ -1111,7 +1112,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com
11111112

11121113
- `GET /view/<chave>/<raiz>/<caminho>`, no mesmo servidor do daemon (só 127.0.0.1). A `<chave>` tem 128 bits aleatórios, é uma por bot, fica só na memória e muda a cada início do daemon; só o app a recebe, em `screens.list`. `<raiz>` é `w` (pasta de trabalho da crew) ou `b` (pasta do bot). O caminho é resolvido de verdade e precisa ficar dentro da raiz: `..`, links para fora e entradas ocultas dão 404, sem dizer por quê. CSS, imagens, fontes e scripts ao lado do HTML carregam pelo mesmo caminho, como num site.
11131114
- Toda resposta leva `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals`: a página roda numa origem opaca, sem acesso ao daemon nem aos cookies e ao armazenamento de 127.0.0.1, também se aberta direto. E ainda `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` e, para um pedido com `Origin: null` (a própria tela buscando um arquivo dela), `Access-Control-Allow-Origin: null`. O tipo sai da extensão (8.4). Acima de 20 MiB, 404.
1114-
- Enquanto um arquivo tem rascunho, o endereço dele serve o rascunho.
1115+
- Enquanto um arquivo tem rascunho, o endereço dele serve o rascunho, com o script do cursor do bot (22.3). O arquivo do disco é servido como está.
11151116
- A CSP do app ganha `frame-src http://127.0.0.1:*`: as telas abrem num `iframe` do endereço delas, com os próprios scripts e as bibliotecas que carregam da internet, o que não funcionaria num `srcdoc`, que herda a CSP do app.
11161117
- O log registra só o status, em `debug`.
11171118

@@ -1121,6 +1122,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com
11211122
- Do JSON parcial, o daemon tira `file_path` (quando a string dele fecha) e o começo de `content` que já chegou, com os escapes completos desfeitos. Só vira rascunho um `.html` ou `.htm` numa das duas raízes.
11221123
- Notificação `screen.draft {botId, path, url, rev, done}`: `url` já aponta para aquela versão (`?rev=<n>`), no máximo uma a cada 250 ms e uma quando a escrita fecha. O rascunho fica até o resultado daquele `tool_use` chegar (gravado, negado ou com erro), e então sai uma última com `done: true`, e o endereço volta a servir o disco. Um arquivo que o dono negou some da área de design. O fim do turno (`result`) também fecha os rascunhos que sobraram.
11231124
- Rascunho não vai para o banco, para o disco nem para o log.
1125+
- **Cursor do bot:** cada rascunho servido leva primeiro um script pequeno do daemon (`<script data-botloft-cursor>`), logo depois do `<!doctype>`, que precisa continuar primeiro para a página manter o modo de layout; sem doctype, no começo. Um doctype ainda sendo escrito fica sem script naquela versão. Quando a página termina de ler (`DOMContentLoaded`) e de novo no `load`, o script acha onde o conteúdo acaba agora: o fim do texto mais novo, ou o centro do elemento mais novo com tamanho, fora `script`, `style`, `template` e `noscript`. Se esse ponto está abaixo de 85% da altura da tela, rola a página para ele ficar a 60%. Um `iframe` de outra origem roda em outro processo e pode ler a página antes de saber o próprio tamanho (`innerHeight` 0, visto no Edge 154); então o script espera o `resize` antes de medir. Por fim manda ao app, por `postMessage`, `{botloft: "cursor", view, point, box}`: `view` é o tamanho da tela em que mediu, e `point` e `box` vêm nos pixels dela: o ponto e a caixa da parte em volta (o elemento mais próximo que não é texto em linha, menos a página inteira). Sem nada visível ainda (o bot escrevendo o CSS), `point` e `box` vão `null`. O script não muda mais nada na página.
11241126

11251127
### 22.4 Protocolo
11261128

@@ -1140,12 +1142,14 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com
11401142
- Até 12 telas no quadro; "Mostrar mais" traz as outras.
11411143
- Sem telas: o mascote e "<bot> ainda não fez nenhuma tela", com a explicação de que cada página HTML que ele escrever aparece ali e se monta enquanto ele escreve.
11421144
- No chat, a linha de um `Write` ou `Edit` de `.html` que não falhou ganha "Ver em telas", que abre o painel nessa tela.
1145+
- **Cursor:** na prancheta e na tela em foco, enquanto o bot escreve, o cursor dele (a seta na cor do bot, com o nome e os três pontos de quem escreve, o mesmo do navegador) desliza até o ponto que o rascunho diz (22.3), e uma moldura na cor do bot marca a parte que está nascendo. Sem ponto ainda, o cursor espera no canto de cima. O app converte as medidas pela largura de `view` (numa prévia com zoom de CSS, o `iframe` vê mais pixels que o aparelho). Cada versão nova carrega por trás e fala antes de ir para a frente: vale a marca do `iframe` que está à mostra, para o cursor e a moldura baterem com o que se vê. Perto da borda direita, o nome passa para a esquerda da seta. O cursor e a moldura ficam em cima da tela, no tamanho do app, não da prancheta, e somem quando a escrita termina.
11431146
- Textos nos três idiomas (15.6).
11441147

11451148
### 22.6 Segurança
11461149

11471150
- A tela roda HTML e JavaScript escritos pelo bot. A CSP de sandbox (22.2) a isola do app e do daemon; o `iframe` no app também usa `sandbox="allow-scripts allow-forms allow-popups allow-modals"`, sem `allow-same-origin`. Um script da tela pode acessar a internet, como a própria página abriria num navegador.
11481151
- A chave só dá leitura, só dos arquivos das duas raízes, e some quando o daemon reinicia.
1152+
- O script do cursor (22.3) roda dentro da mesma sandbox, com a página do bot, e só manda números. O app aceita a mensagem só de um dos seus próprios `iframe`s, lê só números finitos e os prende ao tamanho da página; a página não tem como mover o cursor para fora da prancheta nem mandar outra coisa ao app.
11491153

11501154
### 22.7 Fora desta etapa
11511155

@@ -1160,3 +1164,4 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com
11601164
|---|---|---|
11611165
| **T1** Telas no daemon | rascunho a partir do stream, JSON parcial, `/view` com sandbox, `screens.list`, `screen.draft`, regras do bot, testes com `FakeRuntime` | um `Write` de `.html` transmitido pelo `FakeRuntime` aparece como rascunho em `/view` e vira o arquivo depois do resultado |
11621166
| **T2** Área de design | painel de telas, quadro com zoom, tela em foco, aparelhos, escrita ao vivo, "Ver em telas" no chat, textos nos três idiomas | ver pelo app um bot real escrever uma tela e ela se montar enquanto ele escreve |
1167+
| **T3** Cursor do bot | script do cursor nos rascunhos, cursor e moldura na prancheta e na tela em foco, a prévia com o mesmo script | ver pelo app um bot real escrever uma tela com o cursor dele seguindo cada parte que nasce |

0 commit comments

Comments
 (0)