// retoor <retoor@molodetz.nl>
import FloatingWindow from "../components/FloatingWindow.js";
import { Http } from "../Http.js";
import { assetUrl } from "../assetVersion.js";
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
import { UserScope } from "../userScope.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
const HISTORY_KEY = "devii-terminal-history";
const FONT_KEY = "devii-fontsize";
const FONT_MIN = 10;
const FONT_MAX = 26;
const FONT_DEFAULT = 14;
const HISTORY_LIMIT = 500;
const TRACE_GLYPHS = {
call: "↳",
ok: "✓",
err: "✗",
"plan-gate": "plan required first",
"reflect-trigger": "reflecting on error",
"verify-gate": "verification gate",
compact: "context compaction",
};
const CLEAR_WORDS = new Set(["clear", "/clear"]);
const RESET_WORDS = new Set(["reset", "/reset"]);
const STOP_WORDS = new Set(["stop", "/stop"]);
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
const HELP = [
"Local commands:",
" /help show this help",
" stop cancel the running task, keep the conversation",
" reset cancel the running task and clear the conversation",
" clear clear the conversation",
" exit | close | quit | bye close the terminal",
" /show show devii (the avatar)",
" /hide hide devii",
" /say <text> make devii speak directly",
" /character <name> switch devii's character",
"Anything else is sent to the agent.",
].join("\n");
export default class DeviiTerminalElement extends FloatingWindow {
constructor() {
super();
this.markdown = new Markdown();
this.deviiClient = new DeviiClient();
this.socket = null;
this.history = [];
this.historyIndex = 0;
this.draft = "";
this._historyKey = null;
this._hasFocus = false;
this._unloading = false;
}
get _dragClass() {
return "devii-dragging";
}
get fontControls() {
return true;
}
get _normalizeSize() {
return [760, 600];
}
_fontAtMin() {
return this._readFont() <= FONT_MIN;
}
_fontAtMax() {
return this._readFont() >= FONT_MAX;
}
connectedCallback() {
this._ensureCss();
this._buildChrome();
this._registerWindow();
this._applyFont(this._readFont());
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
this._vv = window.visualViewport || null;
this._vvHandler = () => this._onVisualViewport2();
if (this._vv) {
this._vv.addEventListener("resize", this._vvHandler);
this._vv.addEventListener("scroll", this._vvHandler);
}
window.addEventListener("pagehide", () => this._captureFocusForUnload());
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
this._fetchIdentity().then((id) => {
this._identity = id;
this._initHistory(id);
});
window.addEventListener("focus", () => {
this._checkIdentity();
this._reportVisibility();
});
window.addEventListener("blur", () => this._reportVisibility());
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._checkIdentity();
this._reportVisibility();
});
this.socket = new DeviiSocket({
onOpen: () => {
this._reportVisibility();
this._reportClientInfo();
},
onReady: () => {
this._notice("connected.");
this._reportVisibility();
this._reportClientInfo();
},
onClose: () => this._notice("disconnected, reconnecting..."),
onMessage: (message) => this._onMessage(message),
});
this._connected = false;
this._restoring = true;
const saved = this._loadPersisted();
if (saved && saved.geometry) {
this.geometry = saved.geometry;
this._clampGeometry();
}
let initial = "closed";
if (saved && saved.state) {
initial = saved.state;
} else if (this.hasAttribute("open")) {
initial = "open";
}
this._restoring = false;
this._setState(initial);
this._observeResize();
if (this.state !== "closed") {
this._ensureConnected();
this._focusInput();
}
}
_focusInput() {
window.requestAnimationFrame(() => {
try {
this.input.focus({ preventScroll: true });
} catch (error) {
return;
}
});
}
_onFocusChange(focused) {
if (!focused && this._unloading) return;
this._hasFocus = focused;
this._persist();
}
_captureFocusForUnload() {
this._unloading = true;
this._hasFocus = document.activeElement === this.input;
this._persist();
}
hasStoredState() {
try {
return window.localStorage.getItem(STORAGE_KEY) !== null;
} catch (error) {
return false;
}
}
_loadPersisted() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
_persist() {
if (this._restoring) return;
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ state: this.state, geometry: this.geometry, focused: this._hasFocus }),
);
} catch (error) {
return;
}
}
_persistGeometry() {
this._persist();
}
_historyKeyFor(identity) {
const scope = identity && identity.loggedIn && identity.username
? `user:${identity.username}`
: "guest";
return `${HISTORY_KEY}:${scope}`;
}
_initHistory(identity) {
this._historyKey = this._historyKeyFor(identity);
this.history = this._loadHistory();
this.historyIndex = this.history.length;
this.draft = "";
}
_loadHistory() {
if (!this._historyKey) return [];
try {
const raw = window.localStorage.getItem(this._historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
} catch (error) {
return [];
}
}
_saveHistory() {
if (!this._historyKey) return;
try {
window.localStorage.setItem(this._historyKey, JSON.stringify(this.history));
} catch (error) {
return;
}
}
_ensureConnected() {
if (this._connected) return;
this._connected = true;
this.socket.connect();
}
_ensureCss() {
if (document.getElementById(CSS_ID)) return;
const link = document.createElement("link");
link.id = CSS_ID;
link.rel = "stylesheet";
link.href = assetUrl("/static/css/devii.css");
document.head.appendChild(link);
}
_buildChrome() {
this.innerHTML = "";
this.launcher = document.createElement("button");
this.launcher.type = "button";
this.launcher.className = "devii-launcher";
this.launcher.innerHTML = '<span class="devii-launcher-dot"></span> devii';
this.launcher.addEventListener("click", () => this.open());
this.appendChild(this.launcher);
this.win = document.createElement("div");
this.win.className = "devii-window";
this.win.setAttribute("role", "dialog");
this.win.setAttribute("aria-label", "Devii assistant");
this.win.innerHTML = [
'<div class="devii-titlebar">',
' <span class="devii-title"><span class="devii-dot" aria-hidden="true"></span>Devii</span>',
' <span class="devii-winctl">',
' <button type="button" data-win="font-dec" title="Smaller font">&#8722;</button>',
' <button type="button" data-win="font-inc" title="Larger font">&#43;</button>',
' <button type="button" data-win="minimize" title="Minimize (smallest usable size)">&#9643;</button>',
' <button type="button" data-win="normalize" title="Normalize (comfortable size)">&#9645;</button>',
' <button type="button" data-win="fullscreen" title="Fullscreen">&#9974;</button>',
' <button type="button" data-win="maximize" title="Maximize">&#9633;</button>',
' <button type="button" data-win="close" title="Close">&#10005;</button>',
" </span>",
"</div>",
'<div class="devii-toolbar">',
' <button type="button" data-cmd="/show">show devii</button>',
' <button type="button" data-cmd="/hide">hide devii</button>',
' <button type="button" data-cmd="/clear">clear</button>',
"</div>",
'<div class="devii-output" role="log" aria-live="polite" aria-label="Conversation"></div>',
'<div class="devii-inputrow">',
' <span class="devii-prompt" aria-hidden="true">you&gt;</span>',
' <input class="devii-input" type="text" autocomplete="off" spellcheck="false" aria-label="Message Devii" />',
"</div>",
].join("");
this.appendChild(this.win);
this.output = this.win.querySelector(".devii-output");
this.input = this.win.querySelector(".devii-input");
this.titlebar = this.win.querySelector(".devii-titlebar");
this.win.querySelectorAll(".devii-toolbar button").forEach((button) => {
button.addEventListener("click", () => this._submit(button.dataset.cmd));
});
this.win.querySelectorAll(".devii-winctl button").forEach((button) => {
if (button.title) button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => this._window(button.dataset.win));
});
this.input.addEventListener("keydown", (event) => this._onKey(event));
this.input.addEventListener("focus", () => this._onFocusChange(true));
this.input.addEventListener("blur", () => this._onFocusChange(false));
this.win.addEventListener("click", (event) => {
if (event.target.closest("button, a, input, textarea")) return;
const selection = window.getSelection();
if (selection && selection.toString().length > 0) return;
this.input.focus({ preventScroll: true });
});
this.output.addEventListener("click", (event) => this._onOutputClick(event));
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", (event) => {
if (event.target.closest("button")) return;
this._window("maximize");
});
}
open() {
this._ensureConnected();
this._setState("open");
this.input.focus({ preventScroll: true });
}
close() {
this._setState("closed");
}
toggle() {
if (this.state === "closed") this.open();
else this.close();
}
_contextItems() {
const selection = window.getSelection ? String(window.getSelection()) : "";
return [
{ label: "Copy", disabled: !selection && !this.input.value, onSelect: () => this._copySelection() },
{ label: "Paste", onSelect: () => this._paste() },
{ separator: true },
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
{ label: "Normalize", onSelect: () => this.normalizeWindow() },
{ separator: true },
{ label: "Close", danger: true, onSelect: () => this.close() },
];
}
_copySelection() {
const selection = window.getSelection ? String(window.getSelection()) : "";
const text = selection || this.input.value;
if (!text) return;
this._clipboardWrite(text);
}
async _paste() {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this._error("clipboard read unavailable.");
return;
}
let text = "";
try {
text = await navigator.clipboard.readText();
} catch (error) {
this._error("paste failed.");
return;
}
this._insertInput(text);
}
_insertInput(text) {
if (!text) return;
const start = this.input.selectionStart != null ? this.input.selectionStart : this.input.value.length;
const end = this.input.selectionEnd != null ? this.input.selectionEnd : this.input.value.length;
this.input.value = this.input.value.slice(0, start) + text + this.input.value.slice(end);
const caret = start + text.length;
this.input.focus({ preventScroll: true });
this.input.setSelectionRange(caret, caret);
}
_clipboardWrite(text) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => this._fallbackCopy(text, () => {}));
} else {
this._fallbackCopy(text, () => {});
}
}
_fontKey() {
return `${FONT_KEY}:${UserScope.get()}`;
}
_readFont() {
try {
const value = parseInt(window.localStorage.getItem(this._fontKey()), 10);
return Number.isFinite(value) ? Math.max(FONT_MIN, Math.min(FONT_MAX, value)) : FONT_DEFAULT;
} catch (error) {
return FONT_DEFAULT;
}
}
_applyFont(size) {
this.style.setProperty("--devii-font-size", `${size}px`);
}
_changeFont(delta) {
const next = Math.max(FONT_MIN, Math.min(FONT_MAX, this._readFont() + delta));
try {
window.localStorage.setItem(this._fontKey(), String(next));
} catch (error) {
return this._applyFont(next);
}
this._applyFont(next);
}
_setState(state) {
let resolved = state;
if (state === "open") {
resolved = this._mobile.matches ? "fullscreen" : "normal";
} else if (state !== "closed" && this._mobile.matches) {
resolved = "fullscreen";
}
this.state = resolved;
this.classList.toggle("devii-mobile", this._mobile.matches);
["closed", "normal", "maximized", "fullscreen"].forEach((name) => {
this.classList.toggle(`devii-state-${name}`, name === resolved);
});
if (resolved === "normal") {
this._applyGeometry();
} else {
this.win.style.left = "";
this.win.style.top = "";
this.win.style.width = "";
this.win.style.height = "";
}
const keyboardVisible = this._keyboardVisible;
this.classList.toggle("devii-keyboard-visible", keyboardVisible);
if (resolved === "fullscreen" && keyboardVisible && this._vv) {
this.win.style.height = `${this._vv.height}px`;
this.win.style.top = `${this._vv.offsetTop}px`;
}
this._syncControls();
this._persist();
}
_defaultGeometry() {
const safeHeight = this._safeViewport.height;
const width = Math.min(760, Math.round(window.innerWidth * 0.94));
const height = Math.min(600, Math.round(safeHeight * 0.82));
return {
width,
height,
left: Math.max(12, window.innerWidth - width - 24),
top: Math.max(12, safeHeight - height - 24),
};
}
_onViewportChange() {
if (this.state === "closed") return;
this._setState("open");
}
_onVisualViewport2() {
if (this.state === "closed" || !this._vv) return;
this.classList.toggle("devii-keyboard-visible", this._keyboardVisible);
if (this.state === "fullscreen") {
this.win.style.height = `${this._vv.height}px`;
this.win.style.top = `${this._vv.offsetTop}px`;
}
this._ensureInputVisible();
}
_ensureInputVisible() {
window.requestAnimationFrame(() => {
if (document.activeElement === this.input && this.input.scrollIntoView) {
this.input.scrollIntoView({ block: "end" });
}
if (this.output) this.output.scrollTop = this.output.scrollHeight;
});
}
_onKey(event) {
if (event.key === "Enter") {
event.preventDefault();
const text = this.input.value;
this.input.value = "";
this._submit(text);
} else if (event.key === "ArrowUp" || event.key === "PageUp") {
event.preventDefault();
this._recall(-1);
} else if (event.key === "ArrowDown" || event.key === "PageDown") {
event.preventDefault();
this._recall(1);
} else if (event.key === "Escape") {
this.close();
}
}
_recall(direction) {
if (!this.history.length) return;
if (this.historyIndex === this.history.length) {
this.draft = this.input.value;
}
const target = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
if (target === this.historyIndex) return;
this.historyIndex = target;
const value = target === this.history.length ? this.draft : this.history[target];
this.input.value = value;
this.input.setSelectionRange(value.length, value.length);
}
_submit(raw) {
const text = (raw || "").trim();
if (!text) return;
if (this.history[this.history.length - 1] !== text) {
this.history.push(text);
if (this.history.length > HISTORY_LIMIT) {
this.history = this.history.slice(-HISTORY_LIMIT);
}
this._saveHistory();
}
this.historyIndex = this.history.length;
this.draft = "";
const lowered = text.toLowerCase();
if (STOP_WORDS.has(lowered)) {
this._stopTask();
return;
}
if (RESET_WORDS.has(lowered)) {
this._resetConversation();
return;
}
if (CLEAR_WORDS.has(lowered)) {
this._clearConversation();
return;
}
if (CLOSE_WORDS.has(lowered)) {
this.close();
return;
}
if (text.startsWith("/")) {
this._local(text);
return;
}
this._userLine(text);
if (!this.socket.send({ type: "input", text })) {
this._error("not connected.");
}
}
_stopTask() {
this.socket.send({ type: "stop" });
}
_resetConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "reset" });
}
_clearConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "clear" });
}
async _local(text) {
const [command, ...rest] = text.slice(1).split(" ");
const argument = rest.join(" ").trim();
const avatar = this._avatar();
if (command === "help") {
this._agent(HELP);
} else if (command === "clear") {
this.output.innerHTML = "";
} else if (command === "show") {
this._userLine(text);
if (avatar) await avatar.execute("show", {});
} else if (command === "hide") {
this._userLine(text);
if (avatar) await avatar.execute("hide", {});
} else if (command === "say") {
this._userLine(text);
if (avatar) await avatar.execute("speak", { text: argument });
} else if (command === "character") {
this._userLine(text);
if (avatar) await avatar.execute("switch_character", { name: argument });
} else {
this._error(`unknown command: /${command} (try /help)`);
}
}
async _onMessage(message) {
if (message.type === "reply") {
this._agent(message.text);
} else if (message.type === "history") {
this._renderHistory(message.messages);
} else if (message.type === "clear") {
this.output.innerHTML = "";
} else if (message.type === "status") {
this._notice(message.text);
} else if (message.type === "trace") {
this._trace(message);
} else if (message.type === "error") {
this._error(message.text);
} else if (message.type === "task") {
this._task(message);
} else if (message.type === "avatar" || message.type === "avatar_query") {
await this._avatarRequest(message);
} else if (message.type === "client") {
await this._clientRequest(message);
} else if (message.type === "auth") {
this._onAuth(message);
}
}
_onAuth(message) {
if (message.action === "logout") {
window.location.assign("/auth/logout");
} else if (message.action === "adopt") {
const next = location.pathname + location.search;
window.location.assign("/devii/adopt?next=" + encodeURIComponent(next));
}
}
async _fetchIdentity() {
try {
const data = await Http.getJson("/devii/session");
if (data.baseUrl) {
this.markdown.baseUrl = data.baseUrl;
}
return { loggedIn: !!data.loggedIn, username: data.username || "" };
} catch (error) {
return null;
}
}
async _checkIdentity() {
if (this._checkingIdentity || !this._identity) return;
this._checkingIdentity = true;
try {
const now = await this._fetchIdentity();
if (now && (now.loggedIn !== this._identity.loggedIn || now.username !== this._identity.username)) {
window.location.reload();
}
} finally {
this._checkingIdentity = false;
}
}
_reportVisibility() {
if (!this.socket) return;
this.socket.send({
type: "visibility",
visible: !document.hidden,
focused: document.hasFocus(),
});
}
_reportClientInfo() {
if (!this.socket) return;
let timezone = "";
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch (error) {
timezone = "";
}
this.socket.send({
type: "clientinfo",
timezone,
offset: -new Date().getTimezoneOffset(),
});
}
async _clientRequest(message) {
let result;
try {
result = await this.deviiClient.execute(message.action, message.args || {});
} catch (error) {
result = { error: String(error && error.message ? error.message : error) };
}
this.socket.send({ type: "client_result", id: message.id, result });
}
async _avatarRequest(message) {
const avatar = this._avatar();
let result = { error: "no avatar attached" };
if (avatar) {
try {
result = await avatar.execute(message.action, message.args || {});
} catch (error) {
result = { error: String(error && error.message ? error.message : error) };
}
}
this.socket.send({ type: "avatar_result", id: message.id, result });
}
_avatar() {
if (this._avatarEl && this._avatarEl.isConnected) return this._avatarEl;
const selector = this.getAttribute("avatar");
if (selector) {
const found = document.getElementById(selector) || document.querySelector(selector);
if (found) {
this._avatarEl = found;
return found;
}
}
let element = document.querySelector("devii-avatar");
if (!element) {
element = document.createElement("devii-avatar");
if (selector && /^[a-zA-Z][\w-]*$/.test(selector)) element.id = selector;
document.body.appendChild(element);
}
this._avatarEl = element;
return element;
}
_append(node) {
const pinned = this._atBottom();
this.output.appendChild(node);
this.output.scrollTop = this.output.scrollHeight;
this._scrollOnMediaLoad(node, pinned);
}
_atBottom() {
const slack = 40;
return this.output.scrollHeight - this.output.scrollTop - this.output.clientHeight <= slack;
}
_scrollOnMediaLoad(node, pinned) {
const images = node.querySelectorAll ? node.querySelectorAll("img") : [];
images.forEach((image) => {
if (image.complete) return;
const settle = () => {
if (pinned) this.output.scrollTop = this.output.scrollHeight;
};
image.addEventListener("load", settle, { once: true });
image.addEventListener("error", settle, { once: true });
});
}
_onOutputClick(event) {
const messageButton = event.target.closest(".devii-copy");
if (messageButton) {
this._copyText(messageButton._source || "", messageButton);
return;
}
const button = event.target.closest(".md-copy");
if (!button) return;
const wrap = button.closest(".md-codewrap");
const code = wrap && wrap.querySelector("code");
if (!code) return;
this._copyText(code.textContent, button);
}
_attachCopy(line, source) {
const button = document.createElement("button");
button.type = "button";
button.className = "devii-copy";
button.title = "Copy markdown source";
button.textContent = "Copy";
button._source = source;
line.appendChild(button);
}
_copyText(text, button) {
const label = button.textContent;
const done = () => {
button.textContent = "Copied";
button.classList.add("md-copied");
window.setTimeout(() => {
button.textContent = label;
button.classList.remove("md-copied");
}, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => this._fallbackCopy(text, done));
} else {
this._fallbackCopy(text, done);
}
}
_fallbackCopy(text, done) {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
try {
document.execCommand("copy");
done();
} catch (error) {
return;
}
document.body.removeChild(area);
}
_userLine(text) {
const line = document.createElement("div");
line.className = "devii-line devii-user";
line.textContent = `you> ${text}`;
this._attachCopy(line, text);
this._append(line);
}
_renderHistory(messages) {
this.output.innerHTML = "";
(messages || []).forEach((message) => {
if (message.role === "user") {
this._userLine(message.content);
} else if (message.role === "assistant" && message.content) {
this._agent(message.content);
}
});
}
_agent(text) {
const block = document.createElement("div");
block.className = "devii-line devii-agent";
block.innerHTML = this.markdown.render(text);
this._attachCopy(block, text);
this._append(block);
}
_notice(text) {
const line = document.createElement("div");
line.className = "devii-line devii-notice";
line.textContent = text;
this._append(line);
}
_error(text) {
const line = document.createElement("div");
line.className = "devii-line devii-error";
line.textContent = `[error] ${text}`;
this._append(line);
}
_task(message) {
const verbs = { start: "running...", done: "finished", finished: "finished", error: "failed" };
const head = document.createElement("div");
head.className = "devii-line devii-task";
head.textContent = `[task ${message.label}] ${verbs[message.kind] || message.kind}`;
this._append(head);
if (message.payload && message.kind !== "start") {
this._agent(message.payload);
}
}
_trace(message) {
const glyph = TRACE_GLYPHS[message.event] || message.event;
const line = document.createElement("div");
line.className = "devii-line devii-trace";
if (message.event === "call" || message.event === "ok" || message.event === "err") {
const detail = message.detail ? ` ${message.detail}` : "";
line.textContent = ` ${glyph} ${message.name}${detail}`;
} else {
line.textContent = ` ! ${glyph}`;
}
this._append(line);
}
}
customElements.define("devii-terminal", DeviiTerminalElement);