// retoor <retoor@molodetz.nl>
const MAX_RESULT_CHARS = 8000;
const NAV_DELAY_MS = 150;
export default class DeviiClient {
constructor() {
this._highlights = [];
this._reposition = () => this._repositionHighlights();
}
async execute(action, args = {}) {
try {
switch (action) {
case "get_page_context":
return this._context();
case "run_js":
return await this._runJs(String(args.code || ""));
case "highlight_element":
return this._highlight(String(args.selector || ""), args.label);
case "clear_highlights":
return this._clearHighlights();
case "show_toast":
return this._toast(String(args.text || ""), Number(args.duration_ms) || 4000);
case "scroll_to_element":
return this._scrollTo(String(args.selector || ""));
case "navigate_to":
return this._navigate(String(args.url || ""));
case "reload_page":
return this._reload();
case "open_terminal":
return this._openTerminal(args);
default:
return { error: `unknown client action '${action}'` };
}
} catch (error) {
return { error: String(error && error.message ? error.message : error) };
}
}
_context() {
const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
.map((h) => h.textContent.trim())
.filter(Boolean)
.slice(0, 25);
const selection = String(window.getSelection ? window.getSelection().toString() : "").slice(0, 2000);
const docs = window.DEVPLACE_DOCS || {};
let loggedIn = !!docs.loggedIn;
let username = docs.username || "";
if (!username) {
const nameEl = document.querySelector(".topnav-user-name");
if (nameEl) {
username = nameEl.textContent.trim();
loggedIn = true;
}
}
const pageTypeEl = document.querySelector("[data-page-type]");
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
return {
url: location.href,
path: location.pathname + location.search,
pageType,
title: document.title,
referrer: document.referrer || null,
viewport: { width: window.innerWidth, height: window.innerHeight },
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY) },
selection,
headings,
loggedIn,
username: username || null,
};
}
async _runJs(code) {
if (!code.trim()) return { error: "no code provided" };
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(code);
const value = await fn();
return this._serialize(value);
}
_serialize(value) {
if (value === undefined) return null;
let out;
try {
out = JSON.parse(JSON.stringify(value));
} catch (error) {
out = String(value);
}
const text = typeof out === "string" ? out : JSON.stringify(out);
if (text && text.length > MAX_RESULT_CHARS) {
const base = typeof out === "string" ? out : text;
return base.slice(0, MAX_RESULT_CHARS) + " …[truncated]";
}
return out;
}
_resolve(selector) {
let element = null;
try {
element = document.querySelector(selector);
} catch (error) {
element = null;
}
if (!element) element = this._findByText(selector);
if (!element) throw new Error(`no element matches '${selector}'`);
return element;
}
_findByText(text) {
const needle = String(text).trim();
if (!needle) return null;
const candidates = document.querySelectorAll(
"h1, h2, h3, h4, h5, h6, a, button, [role='button'], label, summary, li, th, td, p, span",
);
let partial = null;
for (const el of candidates) {
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") continue;
const content = (el.textContent || "").trim();
if (!content) continue;
if (content === needle) return el;
if (!partial && content.includes(needle) && content.length <= needle.length + 60) {
partial = el;
}
}
return partial;
}
_scrollTo(selector) {
const element = this._resolve(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
return `scrolled to ${selector}`;
}
_highlight(selector, label) {
const element = this._resolve(selector);
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
const box = document.createElement("div");
box.className = "devii-hl-box";
const callout = label ? document.createElement("div") : null;
if (callout) {
callout.className = "devii-hl-callout";
callout.textContent = String(label);
}
document.body.appendChild(box);
if (callout) document.body.appendChild(callout);
const entry = { element, box, callout };
this._highlights.push(entry);
if (this._highlights.length === 1) {
window.addEventListener("scroll", this._reposition, true);
window.addEventListener("resize", this._reposition);
}
this._place(entry);
return `highlighted ${selector}`;
}
_place(entry) {
const rect = entry.element.getBoundingClientRect();
const pad = 4;
entry.box.style.left = `${rect.left - pad}px`;
entry.box.style.top = `${rect.top - pad}px`;
entry.box.style.width = `${rect.width + pad * 2}px`;
entry.box.style.height = `${rect.height + pad * 2}px`;
if (entry.callout) {
const below = rect.bottom + 8;
entry.callout.style.left = `${Math.max(8, rect.left)}px`;
entry.callout.style.top = `${below}px`;
}
}
_repositionHighlights() {
this._highlights.forEach((entry) => this._place(entry));
}
_clearHighlights() {
const count = this._highlights.length;
this._highlights.forEach((entry) => {
entry.box.remove();
if (entry.callout) entry.callout.remove();
});
this._highlights = [];
window.removeEventListener("scroll", this._reposition, true);
window.removeEventListener("resize", this._reposition);
return `cleared ${count} highlight(s)`;
}
_toast(text, duration) {
if (!text) return { error: "no text provided" };
const toast = document.createElement("div");
toast.className = "devii-toast";
toast.textContent = text;
document.body.appendChild(toast);
window.requestAnimationFrame(() => toast.classList.add("devii-toast-show"));
window.setTimeout(() => {
toast.classList.remove("devii-toast-show");
window.setTimeout(() => toast.remove(), 250);
}, Math.max(1000, duration));
return "toast shown";
}
_navigate(url) {
if (!url) return { error: "no url provided" };
window.setTimeout(() => window.location.assign(url), NAV_DELAY_MS);
return `navigating to ${url}`;
}
_reload() {
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
return "reloading";
}
_openTerminal(args) {
const projectSlug = String(args.project_slug || "").trim();
const instance = String(args.instance || "").trim();
const label = String(args.label || instance || "").trim();
if (!projectSlug || !instance) return { error: "project_slug and instance are required" };
if (!globalThis.app || !globalThis.app.containerTerminals) {
return { error: "terminal manager unavailable" };
}
globalThis.app.containerTerminals.open(projectSlug, instance, label);
return { opened: true, instance };
}
}