Split Markdown editor into dedicated surface

Add a builtin _editor plugin that owns Markdown API/WebSocket sessions, canvas and modal UI, live refresh, tabs, prompt Extras for active-context open files, inline close confirmation, and Close All handling. Route Markdown document artifacts to Editor while keeping Office/Desktop focused on LibreOffice formats, and update Desktop/Office prompts, menus, compatibility shims, and regression coverage.

Alessandro committed May 15, 2026 at 02:41 UTC 330a0c57900a13a28f1e8535058e86bd19ea9dd7
41 files changed +2630 -428
plugins/_desktop/api/desktop_session.py
+7 -22
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from helpers.api import ApiHandler, Request
4 from plugins._desktop.helpers import desktop_session
5 -from plugins._office.helpers import document_store, markdown_sessions
5 +from plugins._office.helpers import document_store
6 from plugins._office.helpers import libreoffice
7
8
@@ -75,7 +75,12 @@ class DesktopSession(ApiHandler):
75
76 ext = str(doc.get("extension") or "").lower()
77 if ext == "md":
78 - return self._open_markdown(doc, input, request)
78 + return {
79 + "ok": False,
80 + "error": "Markdown documents use the Editor surface.",
81 + "requires_editor": True,
82 + "document": _public_doc(doc),
83 + }
84 if ext not in desktop_session.OFFICIAL_EXTENSIONS:
85 return {"ok": False, "error": f".{ext} documents do not use the Desktop surface."}
86
@@ -110,26 +115,6 @@ class DesktopSession(ApiHandler):
115 "mode": "edit",
116 }
117
113 - def _open_markdown(self, doc: dict, input: dict, request: Request) -> dict:
114 - mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
115 - store_session = document_store.create_session(
116 - doc["file_id"],
117 - user_id=str(input.get("user_id") or "agent-zero-user"),
118 - permission="write" if mode == "edit" else "read",
119 - origin=self._origin(request),
120 - )
121 - try:
122 - editor = markdown_sessions.get_manager().open(doc, sid="")
123 - except ValueError as exc:
124 - document_store.close_session(session_id=store_session["session_id"])
125 - return {"ok": False, "error": str(exc)}
126 - return {
127 - **editor,
128 - "store_session_id": store_session["session_id"],
129 - "session_id": editor["session_id"],
130 - "mode": mode,
131 - }
132 -
118 def _save(self, input: dict) -> dict:
119 session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
120 if not session_id:
plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py new
+18
@@ -0,0 +1,18 @@
1 +from __future__ import annotations
2 +
3 +from agent import LoopData
4 +from helpers.extension import Extension
5 +from plugins._desktop.helpers import prompt_context
6 +
7 +
8 +class IncludeDesktopState(Extension):
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + context = prompt_context.build_context()
11 + if not context:
12 + loop_data.extras_temporary.pop("desktop_state", None)
13 + return
14 +
15 + loop_data.extras_temporary["desktop_state"] = self.agent.read_prompt(
16 + "agent.extras.desktop_state.md",
17 + desktop_state=context,
18 + ) if self.agent else context
plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html
-4
@@ -22,10 +22,6 @@
22 <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
23 <span>Open</span>
24 </button>
25 - <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('markdown')">
26 - <span class="material-symbols-outlined" aria-hidden="true">article</span>
27 - <span>Markdown</span>
28 - </button>
25 <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('writer')">
26 <span class="material-symbols-outlined" aria-hidden="true">description</span>
27 <span>Writer</span>
plugins/_desktop/helpers/prompt_context.py new
+18
@@ -0,0 +1,18 @@
1 +from __future__ import annotations
2 +
3 +from plugins._desktop.helpers import desktop_state
4 +
5 +
6 +def build_context() -> str:
7 + if not desktop_state.session_manifest_exists():
8 + return ""
9 + try:
10 + return desktop_state.compact_prompt_context(
11 + desktop_state.collect_state(include_screenshot=False),
12 + )
13 + except Exception as exc:
14 + return (
15 + "[DESKTOP STATE]\n"
16 + f"- unavailable={exc}\n"
17 + "- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
18 + )
plugins/_desktop/prompts/agent.extras.desktop_state.md new
+1
@@ -0,0 +1 @@
1 +{{desktop_state}}
plugins/_desktop/skills/linux-desktop/SKILL.md
+1 -1
@@ -34,7 +34,7 @@ The Desktop is an observe-act-verify control surface. Use this decision hierarch
34
35 Keep these standing rules:
36
37 -1. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
37 +1. Treat Markdown as first-class, but use the separate Editor surface for it. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and let the user open it in Editor instead of Desktop.
38 2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit OOXML compatibility.
39 3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
40 4. Never open the Desktop surface automatically from a tool result if the user has not opened it. Offer an explicit Open in Desktop action instead.
plugins/_desktop/webui/desktop-panel.html
+2 -69
@@ -54,44 +54,14 @@
54 </div>
55 </div>
56
57 - <div class="office-toolbar" x-show="$store.desktop.session && $store.desktop.isMarkdown()" style="display: none;">
58 - <div class="office-toolbar-row">
59 - <div class="office-tool-group office-editor-tools" x-show="$store.desktop.session && $store.desktop.isMarkdown()" style="display: none;">
60 - <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.desktop.canUndo()" @click="$store.desktop.undo()">
61 - <span class="material-symbols-outlined">undo</span>
62 - </button>
63 - <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.desktop.canRedo()" @click="$store.desktop.redo()">
64 - <span class="material-symbols-outlined">redo</span>
65 - </button>
66 - <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.desktop.format('bold')">
67 - <span class="material-symbols-outlined">format_bold</span>
68 - </button>
69 - <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.desktop.format('italic')">
70 - <span class="material-symbols-outlined">format_italic</span>
71 - </button>
72 - <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.desktop.format('list')">
73 - <span class="material-symbols-outlined">format_list_bulleted</span>
74 - </button>
75 - <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.desktop.format('numbered')">
76 - <span class="material-symbols-outlined">format_list_numbered</span>
77 - </button>
78 - <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.desktop.format('table')">
79 - <span class="material-symbols-outlined">table</span>
80 - </button>
81 - </div>
82 -
83 - <span class="office-toolbar-spacer"></span>
84 - </div>
85 - </div>
86 -
57 <div class="office-state-line" x-show="$store.desktop.message || $store.desktop.error || $store.desktop.loading" style="display: none;">
58 <span class="material-symbols-outlined" :class="{ spinning: $store.desktop.loading }" x-text="$store.desktop.loading ? 'progress_activity' : ($store.desktop.error ? 'error' : 'check_circle')"></span>
59 <span x-text="$store.desktop.error || $store.desktop.message || 'Working'"></span>
60 </div>
61
92 - <div class="office-body" :class="{ 'is-source': $store.desktop.isMarkdown() }">
62 + <div class="office-body">
63 <div class="office-editor-wrap" x-show="$store.desktop.session" style="display: none;">
94 - <div class="office-editor-scroll" :class="{ 'is-desktop': $store.desktop.hasOfficialOffice(), 'is-source': $store.desktop.isMarkdown() }" @click.self="$store.desktop.focusEditor()">
64 + <div class="office-editor-scroll" :class="{ 'is-desktop': $store.desktop.hasOfficialOffice() }" @click.self="$store.desktop.focusEditor()">
65 <template x-if="$store.desktop.hasOfficialOffice()">
66 <div
67 class="office-desktop-wrap"
@@ -100,19 +70,6 @@
70 >
71 </div>
72 </template>
103 -
104 - <textarea
105 - class="office-source-editor"
106 - data-office-source
107 - aria-label="Markdown source"
108 - x-show="$store.desktop.isMarkdown()"
109 - x-model="$store.desktop.editorText"
110 - @input="$store.desktop.onSourceInput()"
111 - @blur="$store.desktop.flushInput()"
112 - spellcheck="true"
113 - style="display: none;"
114 - ></textarea>
115 -
73 </div>
74 </div>
75
@@ -733,30 +690,6 @@
690 background: #20242a;
691 }
692
736 - .office-source-editor {
737 - box-sizing: border-box;
738 - flex: 1 1 auto;
739 - width: 100%;
740 - height: 100%;
741 - min-width: 0;
742 - min-height: 0;
743 - margin: 0;
744 - padding: 0;
745 - border: 0;
746 - outline: none;
747 - background: transparent;
748 - box-shadow: none;
749 - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
750 - font-size: 13px;
751 - line-height: 1.65;
752 - resize: none;
753 - }
754 -
755 - textarea:focus {
756 - background: transparent;
757 - filter: brightness(1) !important;
758 - }
759 -
693 .office-panel .spinning {
694 animation: office-spin 0.8s linear infinite;
695 }
plugins/_desktop/webui/desktop-store.js
+1 -7
@@ -535,7 +535,7 @@ const model = {
535 },
536
537 async create(kind = "document", format = "") {
538 - const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
538 + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase();
539 const title = this.defaultTitle(kind, fmt);
540 this.loading = true;
541 this.error = "";
@@ -2295,7 +2295,6 @@ const model = {
2295
2296 defaultTitle(kind, fmt) {
2297 const date = new Date().toISOString().slice(0, 10);
2298 - if (fmt === "md") return `Document ${date}`;
2298 if (fmt === "odt") return `Writer ${date}`;
2299 if (fmt === "docx") return `DOCX ${date}`;
2300 if (kind === "spreadsheet") return `Spreadsheet ${date}`;
@@ -2328,7 +2327,6 @@ const model = {
2327 async runNewMenuAction(action = "") {
2328 const normalized = String(action || "").trim().toLowerCase();
2329 if (normalized === "open") return await this.openFileBrowser();
2331 - if (normalized === "markdown") return await this.create("document", "md");
2330 if (normalized === "writer") return await this.create("document", "odt");
2331 if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods");
2332 if (normalized === "presentation") return await this.create("presentation", "odp");
@@ -2351,10 +2349,6 @@ const model = {
2349 <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
2350 <span>Open</span>
2351 </button>
2354 - <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="markdown">
2355 - <span class="material-symbols-outlined" aria-hidden="true">article</span>
2356 - <span>Markdown</span>
2357 - </button>
2352 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer">
2353 <span class="material-symbols-outlined" aria-hidden="true">description</span>
2354 <span>Writer</span>
plugins/_editor/api/editor_session.py new
+154
@@ -0,0 +1,154 @@
1 +from __future__ import annotations
2 +
3 +from helpers.api import ApiHandler, Request
4 +from plugins._editor.helpers import markdown_sessions
5 +from plugins._office.helpers import document_store
6 +
7 +
8 +class EditorSession(ApiHandler):
9 + async def process(self, input: dict, request: Request) -> dict:
10 + action = str(input.get("action") or "open").lower().strip()
11 + context_id = str(input.get("ctxid") or input.get("context_id") or "").strip()
12 +
13 + if action == "status":
14 + return {
15 + "ok": True,
16 + "open_files": markdown_sessions.get_manager().list_open(context_id=context_id),
17 + }
18 + if action == "home":
19 + return {"ok": True, "path": document_store.default_open_path(context_id)}
20 + if action == "list":
21 + return {
22 + "ok": True,
23 + "open_files": markdown_sessions.get_manager().list_open(
24 + context_id=context_id,
25 + limit=int(input.get("limit") or 20),
26 + ),
27 + }
28 + if action == "activate":
29 + return markdown_sessions.get_manager().activate(str(input.get("session_id") or ""))
30 + if action == "close":
31 + closed = markdown_sessions.get_manager().close(str(input.get("session_id") or ""))
32 + store_session_id = str(input.get("store_session_id") or "").strip()
33 + file_id = str(input.get("file_id") or "").strip()
34 + closed["store_closed"] = document_store.close_session(
35 + session_id=store_session_id,
36 + file_id="" if store_session_id else file_id,
37 + )
38 + return closed
39 + if action == "create":
40 + fmt = str(input.get("format") or "md").lower().lstrip(".")
41 + if fmt != "md":
42 + return {"ok": False, "error": "Editor can only create Markdown documents."}
43 + try:
44 + doc = document_store.create_document(
45 + kind="document",
46 + title=str(input.get("title") or "Untitled"),
47 + fmt="md",
48 + content=str(input.get("content") or ""),
49 + path=str(input.get("path") or ""),
50 + context_id=context_id,
51 + )
52 + except ValueError as exc:
53 + return {"ok": False, "error": str(exc)}
54 + return await self._open_document(doc, input, request, context_id=context_id)
55 + if action == "open":
56 + file_id = str(input.get("file_id") or "").strip()
57 + try:
58 + doc = (
59 + document_store.get_document(file_id)
60 + if file_id
61 + else document_store.register_document(str(input.get("path") or ""), context_id=context_id)
62 + )
63 + except Exception as exc:
64 + return {"ok": False, "error": str(exc)}
65 + return await self._open_document(doc, input, request, context_id=context_id)
66 + if action == "save":
67 + session_id = str(input.get("session_id") or "").strip()
68 + if not session_id:
69 + return {"ok": False, "error": "session_id is required."}
70 + return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
71 + if action == "renamed":
72 + return self._renamed(input, context_id)
73 + if action == "refresh":
74 + return markdown_sessions.get_manager().refresh_document(str(input.get("file_id") or ""))
75 + return {"ok": False, "error": f"Unsupported editor session action: {action}"}
76 +
77 + async def _open_document(
78 + self,
79 + doc: dict,
80 + input: dict,
81 + request: Request,
82 + context_id: str = "",
83 + ) -> dict:
84 + if str(doc.get("extension") or "").lower() != "md":
85 + return {
86 + "ok": False,
87 + "error": f".{doc.get('extension', '')} documents use the Desktop surface.",
88 + "requires_desktop": True,
89 + "document": _public_doc(doc),
90 + }
91 +
92 + mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
93 + store_session = document_store.create_session(
94 + doc["file_id"],
95 + user_id=str(input.get("user_id") or "agent-zero-user"),
96 + permission="write" if mode == "edit" else "read",
97 + origin=self._origin(request),
98 + )
99 + try:
100 + editor = markdown_sessions.get_manager().open(doc, sid="", context_id=context_id)
101 + except ValueError as exc:
102 + document_store.close_session(session_id=store_session["session_id"])
103 + return {"ok": False, "error": str(exc)}
104 + return {
105 + **editor,
106 + "store_session_id": store_session["session_id"],
107 + "session_id": editor["session_id"],
108 + "mode": mode,
109 + }
110 +
111 + def _renamed(self, input: dict, context_id: str = "") -> dict:
112 + file_id = str(input.get("file_id") or "").strip()
113 + path = str(input.get("path") or "").strip()
114 + if not file_id:
115 + return {"ok": False, "error": "file_id is required."}
116 + if not path:
117 + return {"ok": False, "error": "path is required."}
118 + try:
119 + updated = document_store.rename_document(
120 + file_id,
121 + path,
122 + content=input.get("text") if "text" in input else None,
123 + context_id=context_id,
124 + )
125 + markdown_sessions.get_manager().renamed(
126 + file_id,
127 + updated,
128 + text=input.get("text") if "text" in input else None,
129 + )
130 + except Exception as exc:
131 + return {"ok": False, "error": str(exc)}
132 + return {
133 + "ok": True,
134 + "document": _public_doc(updated),
135 + "version": document_store.item_version(updated),
136 + "refreshFiles": False,
137 + }
138 +
139 + def _origin(self, request: Request) -> str:
140 + origin = request.headers.get("Origin") or request.host_url.rstrip("/")
141 + return origin.rstrip("/")
142 +
143 +
144 +def _public_doc(doc: dict) -> dict:
145 + return {
146 + "file_id": doc["file_id"],
147 + "path": document_store.display_path(doc["path"]),
148 + "basename": doc["basename"],
149 + "title": doc["basename"],
150 + "extension": doc["extension"],
151 + "size": doc["size"],
152 + "version": document_store.item_version(doc),
153 + "last_modified": doc["last_modified"],
154 + }
plugins/_editor/api/ws_editor.py new
+63
@@ -0,0 +1,63 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.ws import WsHandler
6 +from helpers.ws_manager import WsResult
7 +from plugins._editor.helpers import markdown_sessions
8 +from plugins._office.helpers import document_store
9 +
10 +
11 +class WsEditor(WsHandler):
12 + async def on_disconnect(self, sid: str) -> None:
13 + markdown_sessions.get_manager().close_sid(sid)
14 +
15 + async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None:
16 + if not event.startswith("editor_"):
17 + return None
18 + try:
19 + if event == "editor_open":
20 + return self._open(data, sid)
21 + if event == "editor_input":
22 + return markdown_sessions.get_manager().input(
23 + str(data.get("session_id") or ""),
24 + text=data.get("text") if "text" in data else None,
25 + patch=data.get("patch") if isinstance(data.get("patch"), dict) else None,
26 + )
27 + if event == "editor_save":
28 + return markdown_sessions.get_manager().save(
29 + str(data.get("session_id") or ""),
30 + text=data.get("text") if "text" in data else None,
31 + )
32 + if event == "editor_activate":
33 + return markdown_sessions.get_manager().activate(str(data.get("session_id") or ""))
34 + if event == "editor_close":
35 + return markdown_sessions.get_manager().close(str(data.get("session_id") or ""))
36 + except FileNotFoundError as exc:
37 + return WsResult.error(code="EDITOR_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
38 + except Exception as exc:
39 + return WsResult.error(code="EDITOR_ERROR", message=str(exc), correlation_id=data.get("correlationId"))
40 +
41 + return WsResult.error(
42 + code="UNKNOWN_EDITOR_EVENT",
43 + message=f"Unknown editor event: {event}",
44 + correlation_id=data.get("correlationId"),
45 + )
46 +
47 + def _open(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
48 + context_id = str(data.get("ctxid") or data.get("context_id") or "")
49 + file_id = str(data.get("file_id") or "").strip()
50 + path = str(data.get("path") or "").strip()
51 + if file_id:
52 + doc = document_store.get_document(file_id)
53 + elif path:
54 + doc = document_store.register_document(path, context_id=context_id)
55 + else:
56 + doc = document_store.create_document(
57 + kind="document",
58 + title=str(data.get("title") or "Untitled"),
59 + fmt="md",
60 + content=str(data.get("content") or ""),
61 + context_id=context_id,
62 + )
63 + return markdown_sessions.get_manager().open(doc, sid=sid, context_id=context_id)
plugins/_editor/extensions/python/message_loop_prompts_after/_55_include_editor_open_files.py new
+21
@@ -0,0 +1,21 @@
1 +from __future__ import annotations
2 +
3 +from agent import LoopData
4 +from helpers.extension import Extension
5 +from plugins._editor.helpers import open_files_context
6 +
7 +
8 +class IncludeEditorOpenFiles(Extension):
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + if not self.agent or not self.agent.context:
11 + return
12 +
13 + context = open_files_context.build_context(self.agent.context.id)
14 + if not context:
15 + loop_data.extras_temporary.pop("editor_open_files", None)
16 + return
17 +
18 + loop_data.extras_temporary["editor_open_files"] = self.agent.read_prompt(
19 + "agent.extras.editor_open_files.md",
20 + editor_open_files=context,
21 + )
plugins/_editor/extensions/python/webui_ws_disconnect/_50_editor.py new
+24
@@ -0,0 +1,24 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from plugins._editor.api.ws_editor import WsEditor
7 +
8 +
9 +class EditorWebuiWsDisconnect(Extension):
10 + async def execute(
11 + self,
12 + instance: Any = None,
13 + sid: str = "",
14 + **kwargs: Any,
15 + ) -> None:
16 + if instance is None:
17 + return
18 + handler = WsEditor(
19 + instance.socketio,
20 + instance.lock,
21 + manager=instance.manager,
22 + namespace=instance.namespace,
23 + )
24 + await handler.on_disconnect(sid)
plugins/_editor/extensions/python/webui_ws_event/_50_editor.py new
+47
@@ -0,0 +1,47 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from helpers.extension import Extension
6 +from helpers.ws_manager import WsResult
7 +from plugins._editor.api.ws_editor import WsEditor
8 +
9 +
10 +class EditorWebuiWsEvents(Extension):
11 + async def execute(
12 + self,
13 + instance: Any = None,
14 + sid: str = "",
15 + event_type: str = "",
16 + data: dict[str, Any] | None = None,
17 + response_data: dict[str, Any] | None = None,
18 + **kwargs: Any,
19 + ) -> None:
20 + if not event_type.startswith("editor_") or instance is None or response_data is None:
21 + return
22 +
23 + handler = WsEditor(
24 + instance.socketio,
25 + instance.lock,
26 + manager=instance.manager,
27 + namespace=instance.namespace,
28 + )
29 + result = await handler.process(event_type, data or {}, sid)
30 + if result is None:
31 + return
32 +
33 + if isinstance(result, WsResult):
34 + payload = result.as_result(
35 + handler_id=handler.identifier,
36 + fallback_correlation_id=(data or {}).get("correlationId"),
37 + )
38 + if payload.get("ok"):
39 + response_data.update(payload.get("data") or {})
40 + else:
41 + response_data["editor_error"] = payload.get("error") or {
42 + "code": "EDITOR_ERROR",
43 + "error": "Editor request failed",
44 + }
45 + return
46 +
47 + response_data.update(result)
plugins/_editor/extensions/webui/right-canvas-panels/editor-panel.html new
+11
@@ -0,0 +1,11 @@
1 +<div
2 + class="right-canvas-surface-panel editor-canvas-surface"
3 + data-surface-id="editor"
4 + :class="{
5 + 'is-active': $store.rightCanvas?.isSurfaceVisible('editor'),
6 + 'is-mounted': $store.rightCanvas?.isSurfaceRendered('editor')
7 + }"
8 + :aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('editor')).toString()"
9 +>
10 + <x-component path="/plugins/_editor/webui/editor-panel.html" mode="canvas"></x-component>
11 +</div>
plugins/_editor/extensions/webui/right_canvas_register_surfaces/register-editor.js new
+48
@@ -0,0 +1,48 @@
1 +import { store as editorStore } from "/plugins/_editor/webui/editor-store.js";
2 +
3 +function waitForElement(selector, timeoutMs = 3000) {
4 + const found = document.querySelector(selector);
5 + if (found) return Promise.resolve(found);
6 + return new Promise((resolve) => {
7 + const timeout = globalThis.setTimeout(() => {
8 + observer.disconnect();
9 + resolve(document.querySelector(selector));
10 + }, timeoutMs);
11 + const observer = new MutationObserver(() => {
12 + const element = document.querySelector(selector);
13 + if (!element) return;
14 + globalThis.clearTimeout(timeout);
15 + observer.disconnect();
16 + resolve(element);
17 + });
18 + observer.observe(document.body, { childList: true, subtree: true });
19 + });
20 +}
21 +
22 +export default async function registerEditorSurface(surfaces) {
23 + surfaces.registerSurface({
24 + id: "editor",
25 + title: "Editor",
26 + icon: "article",
27 + order: 30,
28 + modalPath: "/plugins/_editor/webui/main.html",
29 + beginDockHandoff() {
30 + editorStore.beginSurfaceHandoff?.();
31 + },
32 + finishDockHandoff() {
33 + editorStore.finishSurfaceHandoff?.();
34 + },
35 + cancelDockHandoff() {
36 + editorStore.cancelSurfaceHandoff?.();
37 + },
38 + async open(payload = {}) {
39 + const panel = await waitForElement('[data-surface-id="editor"] .editor-panel');
40 + if (!panel) throw new Error("Editor surface panel did not mount.");
41 + await editorStore.onMount?.(panel, { mode: "canvas" });
42 + await editorStore.onOpen?.(payload);
43 + },
44 + async close() {
45 + await editorStore.cleanup?.();
46 + },
47 + });
48 +}
plugins/_editor/extensions/webui/surfaces_register/register-editor.js new
+3
@@ -0,0 +1,3 @@
1 +import registerEditorSurface from "../right_canvas_register_surfaces/register-editor.js";
2 +
3 +export default registerEditorSurface;
plugins/_editor/helpers/markdown_sessions.py new
+269
@@ -0,0 +1,269 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +import uuid
5 +from dataclasses import dataclass, field
6 +from pathlib import Path
7 +from typing import Any
8 +
9 +from plugins._office.helpers import document_store
10 +
11 +
12 +@dataclass
13 +class MarkdownSession:
14 + session_id: str
15 + file_id: str
16 + sid: str
17 + context_id: str
18 + extension: str
19 + path: str
20 + title: str
21 + text: str = ""
22 + dirty: bool = False
23 + active: bool = False
24 + opened_at: float = field(default_factory=time.time)
25 + updated_at: float = field(default_factory=time.time)
26 + last_active_at: float = field(default_factory=time.time)
27 +
28 +
29 +class MarkdownSessionManager:
30 + """Owns native Editor sessions for Markdown documents."""
31 +
32 + def __init__(self) -> None:
33 + self._sessions: dict[str, MarkdownSession] = {}
34 + self._active_by_context: dict[str, str] = {}
35 +
36 + def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "") -> dict[str, Any]:
37 + ext = str(doc["extension"]).lower()
38 + if ext != "md":
39 + raise ValueError(f"Editor is only available for Markdown. Open .{ext} files in the Desktop.")
40 +
41 + normalized_context = str(context_id or "")
42 + for session in self._sessions.values():
43 + if session.file_id != doc["file_id"] or session.context_id != normalized_context:
44 + continue
45 + if sid:
46 + session.sid = sid
47 + session.path = doc["path"]
48 + session.title = doc["basename"]
49 + self.activate(session.session_id)
50 + return self._payload(session, doc)
51 +
52 + session = MarkdownSession(
53 + session_id=uuid.uuid4().hex,
54 + file_id=doc["file_id"],
55 + sid=sid,
56 + context_id=normalized_context,
57 + extension=ext,
58 + path=doc["path"],
59 + title=doc["basename"],
60 + text=document_store.read_text_for_editor(doc),
61 + )
62 + self._sessions[session.session_id] = session
63 + self.activate(session.session_id)
64 + return self._payload(session, doc)
65 +
66 + def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
67 + session = self._require(session_id)
68 + if text is not None:
69 + session.text = str(text)
70 + elif patch:
71 + session.text = _apply_text_patch(session.text, patch)
72 + session.dirty = True
73 + session.updated_at = time.time()
74 + self.activate(session.session_id)
75 + return {"ok": True, "session_id": session.session_id}
76 +
77 + def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
78 + session = self._require(session_id)
79 + if text is not None:
80 + session.text = str(text)
81 +
82 + updated = document_store.write_markdown(session.file_id, session.text)
83 + session.updated_at = time.time()
84 + session.dirty = False
85 + session.path = updated["path"]
86 + session.title = updated["basename"]
87 + self._refresh_file_sessions(updated, text=session.text, dirty=False)
88 + return {
89 + "ok": True,
90 + "document": _public_doc(updated),
91 + "version": document_store.item_version(updated),
92 + }
93 +
94 + def activate(self, session_id: str) -> dict[str, Any]:
95 + session = self._require(session_id)
96 + now = time.time()
97 + previous_id = self._active_by_context.get(session.context_id)
98 + if previous_id and previous_id in self._sessions:
99 + self._sessions[previous_id].active = False
100 + session.active = True
101 + session.last_active_at = now
102 + session.updated_at = now
103 + self._active_by_context[session.context_id] = session.session_id
104 + return {"ok": True, "session_id": session.session_id}
105 +
106 + def renamed(self, file_id: str, doc: dict[str, Any], text: str | None = None) -> dict[str, Any]:
107 + updated = self._refresh_file_sessions(doc, text=text, dirty=False)
108 + return {"ok": True, "updated": updated, "file_id": file_id}
109 +
110 + def refresh_document(self, file_id: str) -> dict[str, Any]:
111 + normalized = str(file_id or "").strip()
112 + if not normalized:
113 + return {"ok": True, "refreshed": 0, "sessions": []}
114 + try:
115 + doc = document_store.get_document(normalized)
116 + except Exception:
117 + return {"ok": False, "refreshed": 0, "sessions": []}
118 + if str(doc.get("extension") or "").lower() != "md":
119 + return {"ok": True, "refreshed": 0, "sessions": []}
120 +
121 + refreshed = self._refresh_file_sessions(
122 + doc,
123 + text=document_store.read_text_for_editor(doc),
124 + dirty=False,
125 + )
126 + return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
127 +
128 + def list_open(self, context_id: str = "", limit: int = 20) -> list[dict[str, Any]]:
129 + context_id = str(context_id or "")
130 + sessions = [session for session in self._sessions.values() if session.context_id == context_id]
131 + sessions.sort(key=lambda item: (not item.active, -item.last_active_at, -item.updated_at))
132 +
133 + grouped: dict[str, MarkdownSession] = {}
134 + counts: dict[str, int] = {}
135 + for session in sessions:
136 + key = session.file_id or session.path
137 + counts[key] = counts.get(key, 0) + 1
138 + current = grouped.get(key)
139 + if current is None or session.active or session.last_active_at > current.last_active_at:
140 + grouped[key] = session
141 +
142 + result = []
143 + for session in grouped.values():
144 + try:
145 + doc = document_store.get_document(session.file_id)
146 + version = document_store.item_version(doc)
147 + size = doc.get("size", 0)
148 + last_modified = doc.get("last_modified", "")
149 + path = document_store.display_path(doc.get("path", session.path))
150 + title = doc.get("basename") or session.title
151 + except Exception:
152 + version = ""
153 + size = 0
154 + last_modified = ""
155 + path = document_store.display_path(session.path)
156 + title = session.title
157 + result.append({
158 + "session_id": session.session_id,
159 + "file_id": session.file_id,
160 + "title": title,
161 + "extension": session.extension,
162 + "path": path,
163 + "version": version,
164 + "size": size,
165 + "last_modified": last_modified,
166 + "dirty": session.dirty,
167 + "active": session.active,
168 + "open_sessions": counts.get(session.file_id or session.path, 1),
169 + "last_active_at": session.last_active_at,
170 + })
171 +
172 + result.sort(key=lambda item: (not item["active"], -float(item["last_active_at"] or 0)))
173 + safe_limit = max(1, int(limit or 20))
174 + return result[:safe_limit]
175 +
176 + def close(self, session_id: str) -> dict[str, Any]:
177 + session = self._sessions.pop(str(session_id or ""), None)
178 + if not session:
179 + return {"ok": True, "closed": 0}
180 + if self._active_by_context.get(session.context_id) == session.session_id:
181 + replacement = sorted(
182 + [item for item in self._sessions.values() if item.context_id == session.context_id],
183 + key=lambda item: item.last_active_at,
184 + reverse=True,
185 + )
186 + if replacement:
187 + self.activate(replacement[0].session_id)
188 + else:
189 + self._active_by_context.pop(session.context_id, None)
190 + return {"ok": True, "closed": 1, "session_id": session_id}
191 +
192 + def close_sid(self, sid: str) -> int:
193 + doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
194 + for session_id in doomed:
195 + self.close(session_id)
196 + return len(doomed)
197 +
198 + def _refresh_file_sessions(self, doc: dict[str, Any], text: str | None = None, dirty: bool | None = None) -> list[str]:
199 + file_id = str(doc.get("file_id") or "").strip()
200 + refreshed: list[str] = []
201 + for session in self._sessions.values():
202 + if session.file_id != file_id:
203 + continue
204 + if text is not None:
205 + session.text = str(text)
206 + session.path = doc["path"]
207 + session.title = doc["basename"]
208 + if dirty is not None:
209 + session.dirty = dirty
210 + session.updated_at = time.time()
211 + refreshed.append(session.session_id)
212 + return refreshed
213 +
214 + def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]:
215 + return {
216 + "ok": True,
217 + "session_id": session.session_id,
218 + "file_id": session.file_id,
219 + "title": session.title,
220 + "extension": session.extension,
221 + "path": session.path,
222 + "text": session.text,
223 + "dirty": session.dirty,
224 + "active": session.active,
225 + "context_id": session.context_id,
226 + "document": _public_doc(doc),
227 + "version": document_store.item_version(doc),
228 + }
229 +
230 + def _require(self, session_id: str) -> MarkdownSession:
231 + normalized = str(session_id or "").strip()
232 + session = self._sessions.get(normalized)
233 + if not session:
234 + raise FileNotFoundError(f"Editor session not found: {normalized}")
235 + return session
236 +
237 +
238 +def get_manager() -> MarkdownSessionManager:
239 + global _manager
240 + try:
241 + return _manager
242 + except NameError:
243 + _manager = MarkdownSessionManager()
244 + return _manager
245 +
246 +
247 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
248 + return {
249 + "file_id": doc["file_id"],
250 + "path": document_store.display_path(doc["path"]),
251 + "basename": doc["basename"],
252 + "title": doc["basename"],
253 + "extension": doc["extension"],
254 + "size": doc["size"],
255 + "version": document_store.item_version(doc),
256 + "last_modified": doc["last_modified"],
257 + "exists": Path(doc["path"]).exists(),
258 + }
259 +
260 +
261 +def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
262 + if "content" in patch:
263 + return str(patch.get("content") or "")
264 + start = int(patch.get("start") or 0)
265 + end = int(patch.get("end") if patch.get("end") is not None else start)
266 + replacement = str(patch.get("text") or "")
267 + start = max(0, min(len(text), start))
268 + end = max(start, min(len(text), end))
269 + return text[:start] + replacement + text[end:]
plugins/_editor/helpers/open_files_context.py new
+33
@@ -0,0 +1,33 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from plugins._editor.helpers import markdown_sessions
6 +
7 +
8 +def build_context(context_id: str = "", max_items: int = 20) -> str:
9 + files = markdown_sessions.get_manager().list_open(context_id=context_id, limit=max_items)
10 + if not files:
11 + return ""
12 +
13 + lines = [
14 + "These Markdown files are open in the Editor for the active Agent Zero context. Content is omitted; use `document_artifact` with action `read` before content-sensitive edits.",
15 + ]
16 + for item in files:
17 + lines.append(format_open_file_line(item))
18 + lines.append(
19 + "Use `document_artifact` with action `edit` and file_id or path for saved edits; Editor sessions refresh after saved tool results."
20 + )
21 + return "\n".join(lines)
22 +
23 +
24 +def format_open_file_line(item: dict[str, Any]) -> str:
25 + active = "active, " if item.get("active") else ""
26 + dirty = "dirty, " if item.get("dirty") else "saved, "
27 + return (
28 + f"- {item.get('title', 'Untitled')} "
29 + f"(.{item.get('extension', 'md')}, {active}{dirty}"
30 + f"file_id={item.get('file_id', '')}, path={item.get('path', '')}, "
31 + f"version={item.get('version', '')}, size={item.get('size', 0)} bytes, "
32 + f"last_modified={item.get('last_modified', '')}, open_sessions={item.get('open_sessions', 1)})"
33 + )
plugins/_editor/plugin.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: _editor
2 +title: Editor
3 +description: Native Markdown editor surface for Agent Zero canvas and floating modal workflows.
4 +version: 1.0.0
5 +always_enabled: true
plugins/_editor/prompts/agent.extras.editor_open_files.md new
+2
@@ -0,0 +1,2 @@
1 +[EDITOR OPEN FILES]
2 +{{editor_open_files}}
plugins/_editor/webui/editor-panel.html new
+631
@@ -0,0 +1,631 @@
1 +<html>
2 +<head>
3 + <script type="module">
4 + import { store } from "/plugins/_editor/webui/editor-store.js";
5 + </script>
6 +</head>
7 +<body>
8 + <div x-data>
9 + <template x-if="$store.editor">
10 + <div class="editor-panel" x-create="$store.editor.onMount($el, xAttrs($el) || {})" x-destroy="$store.editor.cleanup()">
11 + <div class="editor-shell">
12 + <div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open Markdown files">
13 + <template x-for="tab in $store.editor.visibleTabs()" :key="tab.tab_id">
14 + <div
15 + class="editor-tab-shell"
16 + :class="{ 'is-active': $store.editor.isActiveTab(tab), 'is-dirty': $store.editor.isTabDirty(tab), 'is-pending-close': $store.editor.pendingClose?.tabId === tab.tab_id }"
17 + >
18 + <button
19 + type="button"
20 + class="editor-tab"
21 + role="tab"
22 + :aria-selected="$store.editor.isActiveTab(tab).toString()"
23 + :title="$store.editor.tabLabel(tab)"
24 + @click="$store.editor.selectTab(tab.tab_id)"
25 + >
26 + <span class="material-symbols-outlined editor-tab-icon" aria-hidden="true" x-text="$store.editor.tabIcon(tab)"></span>
27 + <span class="editor-tab-title" x-text="$store.editor.tabTitle(tab)"></span>
28 + </button>
29 + <button
30 + type="button"
31 + class="editor-tab-close"
32 + title="Close file"
33 + aria-label="Close file"
34 + @click.stop="$store.editor.closeTab(tab.tab_id)"
35 + >
36 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
37 + </button>
38 + </div>
39 + </template>
40 + </div>
41 +
42 + <div class="editor-close-confirm" x-show="$store.editor.hasPendingClose()" style="display: none;" role="status">
43 + <span class="material-symbols-outlined editor-close-confirm-icon" aria-hidden="true">warning</span>
44 + <div class="editor-close-confirm-copy">
45 + <span class="editor-close-confirm-title" x-text="$store.editor.pendingCloseTitle()"></span>
46 + <span class="editor-close-confirm-message" x-text="$store.editor.pendingCloseMessage()"></span>
47 + </div>
48 + <div class="editor-close-confirm-actions">
49 + <button
50 + type="button"
51 + class="editor-text-button is-primary"
52 + x-show="$store.editor.pendingCloseHasDirty()"
53 + :disabled="$store.editor.saving"
54 + @click="$store.editor.confirmPendingClose({ save: true })"
55 + >
56 + Save &amp; Close
57 + </button>
58 + <button
59 + type="button"
60 + class="editor-text-button"
61 + :disabled="$store.editor.saving"
62 + @click="$store.editor.confirmPendingClose({ save: false })"
63 + x-text="$store.editor.pendingCloseDiscardLabel()"
64 + ></button>
65 + <button type="button" class="editor-text-button" :disabled="$store.editor.saving" @click="$store.editor.cancelPendingClose()">Cancel</button>
66 + </div>
67 + </div>
68 +
69 + <div class="editor-document-header" x-show="$store.editor.hasActiveFile()" style="display: none;">
70 + <div class="editor-document-title" :title="$store.editor.tabLabel($store.editor.session)">
71 + <span class="material-symbols-outlined editor-document-icon" aria-hidden="true" x-text="$store.editor.tabIcon($store.editor.session)"></span>
72 + <span class="editor-document-name" x-text="$store.editor.tabTitle($store.editor.session)"></span>
73 + <span class="editor-document-dirty" x-show="$store.editor.dirty" aria-hidden="true">*</span>
74 + </div>
75 +
76 + <button
77 + type="button"
78 + class="editor-icon-button editor-document-save-button"
79 + title="Save"
80 + aria-label="Save"
81 + :class="{ 'is-primary': $store.editor.dirty }"
82 + :disabled="$store.editor.saving"
83 + @click="$store.editor.save()"
84 + >
85 + <span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
86 + </button>
87 +
88 + <div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
89 + <button
90 + type="button"
91 + class="editor-icon-button editor-file-menu-button"
92 + title="File actions"
93 + aria-label="File actions"
94 + aria-haspopup="menu"
95 + :aria-expanded="open.toString()"
96 + :disabled="$store.editor.saving"
97 + @click.stop="open = !open"
98 + >
99 + <span class="material-symbols-outlined">more_vert</span>
100 + </button>
101 + <div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
102 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
103 + <span class="material-symbols-outlined" aria-hidden="true">edit</span>
104 + <span>Rename</span>
105 + </button>
106 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading" @click="open = false; $store.editor.closeActiveFile()">
107 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
108 + <span>Close File</span>
109 + </button>
110 + <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.loading || $store.editor.visibleTabs().length === 0" @click="open = false; $store.editor.closeAllFiles()">
111 + <span class="material-symbols-outlined" aria-hidden="true">close</span>
112 + <span>Close All</span>
113 + </button>
114 + </div>
115 + </div>
116 + </div>
117 +
118 + <div class="editor-toolbar" x-show="$store.editor.session && $store.editor.isMarkdown()" style="display: none;">
119 + <div class="editor-toolbar-row">
120 + <div class="editor-tool-group editor-source-tools">
121 + <button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
122 + <span class="material-symbols-outlined">undo</span>
123 + </button>
124 + <button type="button" class="editor-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.editor.canRedo()" @click="$store.editor.redo()">
125 + <span class="material-symbols-outlined">redo</span>
126 + </button>
127 + <button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" @click="$store.editor.format('bold')">
128 + <span class="material-symbols-outlined">format_bold</span>
129 + </button>
130 + <button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" @click="$store.editor.format('italic')">
131 + <span class="material-symbols-outlined">format_italic</span>
132 + </button>
133 + <button type="button" class="editor-icon-button" title="List" aria-label="List" @click="$store.editor.format('list')">
134 + <span class="material-symbols-outlined">format_list_bulleted</span>
135 + </button>
136 + <button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.editor.format('numbered')">
137 + <span class="material-symbols-outlined">format_list_numbered</span>
138 + </button>
139 + <button type="button" class="editor-icon-button" title="Table" aria-label="Table" @click="$store.editor.format('table')">
140 + <span class="material-symbols-outlined">table</span>
141 + </button>
142 + </div>
143 + <span class="editor-toolbar-spacer"></span>
144 + </div>
145 + </div>
146 +
147 + <div class="editor-state-line" x-show="$store.editor.message || $store.editor.error || $store.editor.loading" style="display: none;">
148 + <span class="material-symbols-outlined" :class="{ spinning: $store.editor.loading }" x-text="$store.editor.loading ? 'progress_activity' : ($store.editor.error ? 'error' : 'check_circle')"></span>
149 + <span x-text="$store.editor.error || $store.editor.message || 'Working'"></span>
150 + </div>
151 +
152 + <div class="editor-body">
153 + <div class="editor-wrap" x-show="$store.editor.session" style="display: none;">
154 + <div class="editor-scroll" @click.self="$store.editor.focusEditor()">
155 + <textarea
156 + class="editor-source-editor"
157 + data-editor-source
158 + aria-label="Markdown source"
159 + x-model="$store.editor.editorText"
160 + @input="$store.editor.onSourceInput()"
161 + @blur="$store.editor.flushInput()"
162 + spellcheck="true"
163 + ></textarea>
164 + </div>
165 + </div>
166 +
167 + <div class="editor-empty" x-show="!$store.editor.session && !$store.editor.loading" style="display: none;">
168 + <div class="editor-empty-actions">
169 + <button type="button" class="editor-icon-button editor-command-button" @click="$store.editor.runNewMenuAction('open')">
170 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
171 + <span class="editor-button-label">Open</span>
172 + </button>
173 + <button type="button" class="editor-icon-button editor-command-button" @click="$store.editor.runNewMenuAction('markdown')">
174 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
175 + <span class="editor-button-label">Markdown</span>
176 + </button>
177 + </div>
178 + </div>
179 + </div>
180 + </div>
181 + </div>
182 + </template>
183 + </div>
184 +
185 + <style>
186 + .editor-panel,
187 + .editor-shell {
188 + display: flex;
189 + flex: 1 1 auto;
190 + flex-direction: column;
191 + width: 100%;
192 + height: 100%;
193 + min-width: 0;
194 + min-height: 0;
195 + background: var(--color-background);
196 + color: var(--color-text);
197 + }
198 +
199 + .editor-panel {
200 + container-type: inline-size;
201 + }
202 +
203 + .modal-inner.editor-modal {
204 + box-sizing: border-box;
205 + width: min(1040px, calc(100vw - 32px));
206 + height: min(760px, calc(100vh - 32px));
207 + min-width: min(640px, calc(100vw - 16px));
208 + min-height: min(460px, calc(100vh - 16px));
209 + max-width: none;
210 + max-height: none;
211 + resize: both;
212 + overflow: hidden;
213 + }
214 +
215 + .modal-inner.editor-modal.is-focus-mode {
216 + left: 8px !important;
217 + top: 8px !important;
218 + width: calc(100vw - 16px) !important;
219 + height: calc(100vh - 16px) !important;
220 + transform: none !important;
221 + }
222 +
223 + .modal-inner.editor-modal .modal-scroll,
224 + .modal-inner.editor-modal .modal-bd.editor-modal-body,
225 + .modal-inner.editor-modal .modal-bd.editor-modal-body > x-component,
226 + .modal-inner.editor-modal .modal-bd.editor-modal-body > x-component > div[x-data],
227 + .modal-inner.editor-modal .modal-bd.editor-modal-body > x-component > div[x-data] > .editor-panel {
228 + display: flex;
229 + flex: 1 1 auto;
230 + min-width: 0;
231 + min-height: 0;
232 + width: 100%;
233 + height: 100%;
234 + max-height: none;
235 + overflow: hidden;
236 + padding: 0;
237 + }
238 +
239 + .modal-inner.editor-modal .modal-header {
240 + grid-template-columns: minmax(0, 1fr) repeat(4, auto);
241 + }
242 +
243 + .editor-tabs {
244 + display: flex;
245 + gap: 6px;
246 + min-height: 42px;
247 + padding: 7px 10px;
248 + overflow-x: auto;
249 + overflow-y: hidden;
250 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
251 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
252 + }
253 +
254 + .editor-tab-shell {
255 + display: grid;
256 + grid-template-columns: minmax(0, 1fr) 28px;
257 + align-items: center;
258 + min-width: 150px;
259 + max-width: 240px;
260 + }
261 +
262 + .editor-tab,
263 + .editor-tab-close,
264 + .editor-icon-button {
265 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
266 + border-radius: 8px;
267 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
268 + color: inherit;
269 + cursor: pointer;
270 + transition: border-color 120ms ease, background 120ms ease;
271 + }
272 +
273 + .editor-tab {
274 + display: flex;
275 + align-items: center;
276 + gap: 6px;
277 + min-width: 0;
278 + height: 28px;
279 + padding: 0 8px;
280 + border-top-right-radius: 0;
281 + border-bottom-right-radius: 0;
282 + text-align: left;
283 + }
284 +
285 + .editor-tab-close {
286 + display: grid;
287 + place-items: center;
288 + height: 28px;
289 + padding: 0;
290 + border-left: 0;
291 + border-top-left-radius: 0;
292 + border-bottom-left-radius: 0;
293 + }
294 +
295 + .editor-tab-shell.is-active .editor-tab,
296 + .editor-tab-shell.is-active .editor-tab-close,
297 + .editor-icon-button.is-primary {
298 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
299 + background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
300 + }
301 +
302 + .editor-tab-shell.is-pending-close .editor-tab,
303 + .editor-tab-shell.is-pending-close .editor-tab-close {
304 + border-color: color-mix(in srgb, #d98b2b, var(--color-border) 30%);
305 + background: color-mix(in srgb, #d98b2b, var(--color-panel) 88%);
306 + }
307 +
308 + .editor-tab-shell.is-dirty .editor-tab-title::after {
309 + content: " *";
310 + color: #2ca58d;
311 + }
312 +
313 + .editor-tab-title,
314 + .editor-document-name {
315 + min-width: 0;
316 + overflow: hidden;
317 + text-overflow: ellipsis;
318 + white-space: nowrap;
319 + }
320 +
321 + .editor-tab-title {
322 + font-size: 12px;
323 + font-weight: 700;
324 + letter-spacing: 0;
325 + }
326 +
327 + .editor-document-header,
328 + .editor-toolbar,
329 + .editor-state-line,
330 + .editor-close-confirm {
331 + display: flex;
332 + align-items: center;
333 + gap: 8px;
334 + border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
335 + padding: 7px 10px;
336 + min-width: 0;
337 + }
338 +
339 + .editor-document-header {
340 + min-height: 38px;
341 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
342 + }
343 +
344 + .editor-close-confirm {
345 + min-height: 44px;
346 + background: color-mix(in srgb, #d98b2b 9%, var(--color-background));
347 + color: var(--color-text);
348 + }
349 +
350 + .editor-close-confirm-icon {
351 + color: #d98b2b;
352 + font-size: 20px;
353 + }
354 +
355 + .editor-close-confirm-copy {
356 + display: flex;
357 + flex: 1 1 auto;
358 + flex-direction: column;
359 + min-width: 0;
360 + gap: 2px;
361 + }
362 +
363 + .editor-close-confirm-title {
364 + overflow: hidden;
365 + text-overflow: ellipsis;
366 + white-space: nowrap;
367 + font-size: 13px;
368 + font-weight: 800;
369 + letter-spacing: 0;
370 + }
371 +
372 + .editor-close-confirm-message {
373 + overflow: hidden;
374 + text-overflow: ellipsis;
375 + white-space: nowrap;
376 + color: var(--color-text-secondary);
377 + font-size: 12px;
378 + letter-spacing: 0;
379 + }
380 +
381 + .editor-close-confirm-actions {
382 + display: flex;
383 + flex: 0 0 auto;
384 + align-items: center;
385 + gap: 6px;
386 + }
387 +
388 + .editor-document-title {
389 + display: flex;
390 + align-items: center;
391 + gap: 7px;
392 + min-width: 0;
393 + flex: 1 1 auto;
394 + font-size: 13px;
395 + font-weight: 750;
396 + letter-spacing: 0;
397 + }
398 +
399 + .editor-document-dirty {
400 + color: #2ca58d;
401 + font-weight: 800;
402 + }
403 +
404 + .editor-toolbar {
405 + padding: 6px 10px;
406 + overflow: hidden;
407 + background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
408 + }
409 +
410 + .editor-toolbar-row,
411 + .editor-tool-group {
412 + display: flex;
413 + align-items: center;
414 + gap: 5px;
415 + min-width: 0;
416 + }
417 +
418 + .editor-toolbar-row {
419 + width: 100%;
420 + overflow-x: auto;
421 + overflow-y: hidden;
422 + }
423 +
424 + .editor-toolbar-spacer {
425 + flex: 1 1 16px;
426 + min-width: 8px;
427 + }
428 +
429 + .editor-icon-button {
430 + display: inline-grid;
431 + place-items: center;
432 + width: 32px;
433 + height: 32px;
434 + min-width: 32px;
435 + padding: 0;
436 + }
437 +
438 + .editor-icon-button:hover:not(:disabled),
439 + .editor-tab:hover,
440 + .editor-tab-close:hover {
441 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
442 + background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
443 + }
444 +
445 + .editor-icon-button:disabled {
446 + cursor: default;
447 + opacity: 0.42;
448 + }
449 +
450 + .editor-file-actions,
451 + .editor-header-actions {
452 + position: relative;
453 + display: inline-flex;
454 + align-items: center;
455 + flex: 0 0 auto;
456 + }
457 +
458 + .editor-new-menu {
459 + position: absolute;
460 + top: calc(100% + 6px);
461 + right: 0;
462 + z-index: 4000;
463 + min-width: 184px;
464 + padding: 5px;
465 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
466 + border-radius: 8px;
467 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
468 + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
469 + }
470 +
471 + .editor-new-menu[hidden] {
472 + display: none;
473 + }
474 +
475 + .editor-new-menu-item,
476 + .editor-header-new-button {
477 + appearance: none;
478 + display: flex;
479 + align-items: center;
480 + gap: 8px;
481 + border: 1px solid transparent;
482 + background: transparent;
483 + color: var(--color-text);
484 + cursor: pointer;
485 + font: inherit;
486 + font-size: 12px;
487 + font-weight: 700;
488 + letter-spacing: 0;
489 + line-height: 1;
490 + white-space: nowrap;
491 + }
492 +
493 + .editor-header-new-button {
494 + justify-content: center;
495 + height: 34px;
496 + padding: 0 9px 0 8px;
497 + border-radius: 7px;
498 + opacity: 0.82;
499 + }
500 +
501 + .editor-new-menu-item {
502 + width: 100%;
503 + height: 32px;
504 + padding: 0 8px;
505 + border-radius: 6px;
506 + text-align: left;
507 + }
508 +
509 + .editor-text-button {
510 + appearance: none;
511 + height: 28px;
512 + padding: 0 9px;
513 + border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
514 + border-radius: 7px;
515 + background: color-mix(in srgb, var(--color-panel), var(--color-background) 18%);
516 + color: var(--color-text);
517 + cursor: pointer;
518 + font: inherit;
519 + font-size: 12px;
520 + font-weight: 750;
521 + letter-spacing: 0;
522 + white-space: nowrap;
523 + }
524 +
525 + .editor-text-button.is-primary {
526 + border-color: color-mix(in srgb, #2c7be5, var(--color-border) 35%);
527 + background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
528 + }
529 +
530 + .editor-header-new-button:hover,
531 + .editor-header-actions.is-open .editor-header-new-button,
532 + .editor-new-menu-item:hover,
533 + .editor-text-button:hover:not(:disabled) {
534 + opacity: 1;
535 + border-color: color-mix(in srgb, var(--color-primary) 24%, transparent);
536 + background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
537 + }
538 +
539 + .editor-text-button:disabled {
540 + cursor: default;
541 + opacity: 0.45;
542 + }
543 +
544 + @container (max-width: 560px) {
545 + .editor-close-confirm {
546 + align-items: stretch;
547 + flex-direction: column;
548 + }
549 +
550 + .editor-close-confirm-actions {
551 + flex-wrap: wrap;
552 + }
553 + }
554 +
555 + .editor-body,
556 + .editor-wrap,
557 + .editor-scroll {
558 + display: flex;
559 + flex: 1 1 auto;
560 + min-width: 0;
561 + min-height: 0;
562 + }
563 +
564 + .editor-body {
565 + position: relative;
566 + flex-direction: column;
567 + overflow: hidden;
568 + }
569 +
570 + .editor-wrap,
571 + .editor-scroll {
572 + width: 100%;
573 + height: 100%;
574 + }
575 +
576 + .editor-source-editor {
577 + box-sizing: border-box;
578 + width: 100%;
579 + height: 100%;
580 + min-width: 0;
581 + min-height: 0;
582 + resize: none;
583 + border: 0;
584 + outline: none;
585 + padding: 18px 20px;
586 + background: var(--color-background);
587 + color: var(--color-text);
588 + font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
589 + letter-spacing: 0;
590 + }
591 +
592 + .editor-empty {
593 + display: grid;
594 + flex: 1 1 auto;
595 + place-items: center;
596 + align-content: center;
597 + gap: 12px;
598 + color: color-mix(in srgb, var(--color-text) 70%, transparent);
599 + }
600 +
601 + .editor-empty-actions {
602 + display: flex;
603 + gap: 8px;
604 + flex-wrap: wrap;
605 + justify-content: center;
606 + }
607 +
608 + .editor-command-button {
609 + display: inline-flex;
610 + width: auto;
611 + max-width: 140px;
612 + padding: 0 9px;
613 + gap: 6px;
614 + }
615 +
616 + .editor-button-label {
617 + overflow: hidden;
618 + text-overflow: ellipsis;
619 + white-space: nowrap;
620 + font-size: 11px;
621 + font-weight: 700;
622 + }
623 +
624 + .editor-state-line {
625 + min-height: 34px;
626 + font-size: 12px;
627 + color: color-mix(in srgb, var(--color-text) 80%, transparent);
628 + }
629 + </style>
630 +</body>
631 +</html>
plugins/_editor/webui/editor-store.js new
+926
@@ -0,0 +1,926 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import { getNamespacedClient } from "/js/websocket.js";
4 +import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +
6 +const editorSocket = getNamespacedClient("/ws");
7 +editorSocket.addHandlers(["ws_webui"]);
8 +
9 +const SAVE_MESSAGE_MS = 1800;
10 +const INPUT_PUSH_DELAY_MS = 650;
11 +const MAX_HISTORY = 80;
12 +
13 +function currentContextId() {
14 + try {
15 + return globalThis.getContext?.() || "";
16 + } catch {
17 + return "";
18 + }
19 +}
20 +
21 +function basename(path = "") {
22 + const value = String(path || "").split("?")[0].split("#")[0];
23 + return value.split("/").filter(Boolean).pop() || "Untitled";
24 +}
25 +
26 +function extensionOf(path = "") {
27 + const name = basename(path).toLowerCase();
28 + const index = name.lastIndexOf(".");
29 + return index >= 0 ? name.slice(index + 1) : "";
30 +}
31 +
32 +function parentPath(path = "") {
33 + const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
34 + const index = normalized.lastIndexOf("/");
35 + if (index <= 0) return "/";
36 + return normalized.slice(0, index);
37 +}
38 +
39 +function uniqueTabId(session = {}) {
40 + return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
41 +}
42 +
43 +function editorContainsFocus(element) {
44 + const active = document.activeElement;
45 + return Boolean(element && active && (element === active || element.contains(active)));
46 +}
47 +
48 +function placeCaretAtEnd(element) {
49 + if (!element) return;
50 + if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
51 + const length = element.value?.length || 0;
52 + element.selectionStart = length;
53 + element.selectionEnd = length;
54 + return;
55 + }
56 + const selection = globalThis.getSelection?.();
57 + const range = document.createRange?.();
58 + if (!selection || !range) return;
59 + range.selectNodeContents(element);
60 + range.collapse(false);
61 + selection.removeAllRanges();
62 + selection.addRange(range);
63 +}
64 +
65 +function normalizeMarkdown(doc = {}) {
66 + const path = doc.path || "";
67 + const extension = String(doc.extension || extensionOf(path)).toLowerCase();
68 + return {
69 + ...doc,
70 + extension,
71 + title: doc.title || doc.basename || basename(path),
72 + basename: doc.basename || basename(path),
73 + path,
74 + };
75 +}
76 +
77 +function normalizeSession(payload = {}) {
78 + const document = normalizeMarkdown(payload.document || payload);
79 + return {
80 + ...payload,
81 + document,
82 + extension: String(payload.extension || document.extension || "").toLowerCase(),
83 + file_id: payload.file_id || document.file_id || "",
84 + path: document.path || payload.path || "",
85 + title: payload.title || document.title || document.basename || basename(document.path),
86 + tab_id: uniqueTabId(payload),
87 + text: String(payload.text || ""),
88 + dirty: Boolean(payload.dirty),
89 + active: Boolean(payload.active),
90 + };
91 +}
92 +
93 +function documentLabel(document = {}) {
94 + return document.title || document.basename || basename(document.path);
95 +}
96 +
97 +async function callEditor(action, payload = {}) {
98 + return await callJsonApi("/plugins/_editor/editor_session", {
99 + action,
100 + ctxid: currentContextId(),
101 + ...payload,
102 + });
103 +}
104 +
105 +async function requestEditor(eventType, payload = {}, timeoutMs = 5000) {
106 + const response = await editorSocket.request(eventType, {
107 + ctxid: currentContextId(),
108 + ...payload,
109 + }, { timeoutMs });
110 + const results = Array.isArray(response?.results) ? response.results : [];
111 + const first = results.find((item) => item?.ok === true && isEditorSocketData(item?.data))
112 + || results.find((item) => item?.ok === true);
113 + if (!first) {
114 + const error = results.find((item) => item?.error)?.error;
115 + throw new Error(error?.error || error?.code || `${eventType} failed`);
116 + }
117 + if (first.data?.editor_error) {
118 + const error = first.data.editor_error;
119 + throw new Error(error.error || error.code || `${eventType} failed`);
120 + }
121 + return first.data || {};
122 +}
123 +
124 +function isEditorSocketData(data) {
125 + if (!data || typeof data !== "object") return false;
126 + return (
127 + Object.prototype.hasOwnProperty.call(data, "editor_error")
128 + || Object.prototype.hasOwnProperty.call(data, "ok")
129 + || Object.prototype.hasOwnProperty.call(data, "session_id")
130 + || Object.prototype.hasOwnProperty.call(data, "document")
131 + );
132 +}
133 +
134 +const model = {
135 + status: null,
136 + tabs: [],
137 + activeTabId: "",
138 + session: null,
139 + loading: false,
140 + saving: false,
141 + dirty: false,
142 + error: "",
143 + message: "",
144 + pendingClose: null,
145 + editorText: "",
146 + _root: null,
147 + _mode: "modal",
148 + _initialized: false,
149 + _saveMessageTimer: null,
150 + _inputTimer: null,
151 + _history: [],
152 + _historyIndex: -1,
153 + _pendingFocus: false,
154 + _pendingFocusEnd: true,
155 + _focusAttempts: 0,
156 + _headerCleanup: null,
157 + _surfaceHandoff: false,
158 +
159 + async init() {
160 + if (this._initialized) return;
161 + this._initialized = true;
162 + await this.refresh();
163 + },
164 +
165 + async onMount(element = null, options = {}) {
166 + await this.init();
167 + if (element) this._root = element;
168 + this._mode = options?.mode === "canvas" ? "canvas" : "modal";
169 + if (this._mode === "modal") this.setupMarkdownModal(element);
170 + this.queueRender();
171 + },
172 +
173 + async onOpen(payload = {}) {
174 + await this.init();
175 + await this.refresh();
176 + if (payload?.path || payload?.file_id) {
177 + await this.openSession({
178 + path: payload.path || "",
179 + file_id: payload.file_id || "",
180 + refresh: payload.refresh === true,
181 + source: payload.source || "",
182 + });
183 + }
184 + },
185 +
186 + beforeHostHidden() {
187 + this.flushInput();
188 + },
189 +
190 + cleanup() {
191 + this.flushInput();
192 + this._headerCleanup?.();
193 + this._headerCleanup = null;
194 + if (this._mode === "modal") this._root = null;
195 + },
196 +
197 + beginSurfaceHandoff() {
198 + this._surfaceHandoff = true;
199 + this.flushInput();
200 + },
201 +
202 + finishSurfaceHandoff() {
203 + this._surfaceHandoff = false;
204 + },
205 +
206 + cancelSurfaceHandoff() {
207 + this._surfaceHandoff = false;
208 + },
209 +
210 + async refresh() {
211 + try {
212 + const status = await callEditor("status");
213 + this.status = status || {};
214 + this.error = "";
215 + } catch (error) {
216 + this.error = error instanceof Error ? error.message : String(error);
217 + }
218 + },
219 +
220 + async create(kind = "document", format = "") {
221 + const fmt = "md";
222 + const title = this.defaultTitle(kind, fmt);
223 + await this.openSession({
224 + action: "create",
225 + kind: "document",
226 + format: fmt,
227 + title,
228 + });
229 + },
230 +
231 + async openFileBrowser() {
232 + let workdirPath = "/a0/usr/workdir";
233 + try {
234 + const response = await callJsonApi("settings_get", null);
235 + workdirPath = response?.settings?.workdir_path || workdirPath;
236 + } catch {
237 + try {
238 + const home = await callEditor("home");
239 + workdirPath = home?.path || workdirPath;
240 + } catch {
241 + // The file browser can still open with the static fallback.
242 + }
243 + }
244 + await fileBrowserStore.open(workdirPath);
245 + },
246 +
247 + async openPath(path) {
248 + await this.openSession({ path: String(path || "") });
249 + },
250 +
251 + async openSession(payload = {}) {
252 + this.loading = true;
253 + this.error = "";
254 + try {
255 + const response = await callEditor(payload.action || "open", payload);
256 + if (response?.ok === false) {
257 + this.error = response.error || "Markdown could not be opened.";
258 + return null;
259 + }
260 + if (response?.requires_desktop) {
261 + const document = normalizeMarkdown(response.document || response);
262 + this.setMessage(`${documentLabel(document)} uses the Desktop surface.`);
263 + await this.refresh();
264 + return response;
265 + }
266 + const session = normalizeSession(response);
267 + this.installSession(session);
268 + await this.refresh();
269 + return session;
270 + } catch (error) {
271 + this.error = error instanceof Error ? error.message : String(error);
272 + return null;
273 + } finally {
274 + this.loading = false;
275 + }
276 + },
277 +
278 + installSession(session) {
279 + const existingIndex = this.tabs.findIndex((tab) => (
280 + (session.file_id && tab.file_id === session.file_id)
281 + || (session.path && tab.path === session.path)
282 + ));
283 + if (existingIndex >= 0) {
284 + this.tabs.splice(existingIndex, 1, { ...this.tabs[existingIndex], ...session, tab_id: this.tabs[existingIndex].tab_id });
285 + this.activeTabId = this.tabs[existingIndex].tab_id;
286 + } else {
287 + this.tabs.push(session);
288 + this.activeTabId = session.tab_id;
289 + }
290 + this.selectTab(this.activeTabId);
291 + },
292 +
293 + selectTab(tabId, options = {}) {
294 + this.syncEditorText();
295 + const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
296 + this.session = tab;
297 + this.activeTabId = tab?.tab_id || "";
298 + this.editorText = String(tab?.text || "");
299 + this.dirty = Boolean(tab?.dirty);
300 + this.resetHistory(this.editorText);
301 + if (tab?.session_id) {
302 + requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {});
303 + }
304 + this.queueRender({ focus: Boolean(tab) && options.focus !== false });
305 + },
306 +
307 + ensureActiveTab() {
308 + if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
309 + if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
310 + },
311 +
312 + isActiveTab(tab) {
313 + return Boolean(tab && tab.tab_id === this.activeTabId);
314 + },
315 +
316 + isTabDirty(tab) {
317 + return Boolean(tab?.dirty || (this.isActiveTab(tab) && this.dirty));
318 + },
319 +
320 + hasPendingClose() {
321 + return Boolean(this.pendingClose);
322 + },
323 +
324 + pendingCloseTitle() {
325 + const pending = this.pendingClose;
326 + if (!pending) return "";
327 + if (pending.kind === "all") {
328 + return `Close ${pending.totalCount || 0} open files?`;
329 + }
330 + const tab = this.tabs.find((item) => item.tab_id === pending.tabId);
331 + return `Close ${this.tabTitle(tab || {})}?`;
332 + },
333 +
334 + pendingCloseMessage() {
335 + const pending = this.pendingClose;
336 + if (!pending) return "";
337 + const dirtyCount = Number(pending.dirtyCount || 0);
338 + if (pending.kind === "all") {
339 + if (dirtyCount === 0) return "All open Markdown files will be closed.";
340 + return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`;
341 + }
342 + if (dirtyCount > 0) return "This file has unsaved changes.";
343 + return "This file will be closed.";
344 + },
345 +
346 + pendingCloseHasDirty() {
347 + return Number(this.pendingClose?.dirtyCount || 0) > 0;
348 + },
349 +
350 + pendingCloseDiscardLabel() {
351 + return this.pendingCloseHasDirty() ? "Discard" : "Close";
352 + },
353 +
354 + beginCloseConfirmation(kind, tabIds = []) {
355 + const ids = tabIds.filter(Boolean);
356 + const tabs = ids.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean);
357 + const dirtyCount = tabs.filter((tab) => this.isTabDirty(tab)).length;
358 + this.pendingClose = {
359 + kind,
360 + tabId: kind === "single" ? ids[0] || "" : "",
361 + tabIds: ids,
362 + totalCount: tabs.length,
363 + dirtyCount,
364 + };
365 + if (kind === "single" && ids[0]) {
366 + this.selectTab(ids[0], { focus: false });
367 + }
368 + },
369 +
370 + cancelPendingClose() {
371 + this.pendingClose = null;
372 + },
373 +
374 + async confirmPendingClose(options = {}) {
375 + const pending = this.pendingClose;
376 + if (!pending || this.loading) return;
377 + this.pendingClose = null;
378 + const save = options.save === true;
379 + if (pending.kind === "all") {
380 + await this.closeAllFiles({ confirm: false, save, tabIds: pending.tabIds || [] });
381 + return;
382 + }
383 + await this.closeTab(pending.tabId, { confirm: false, save });
384 + },
385 +
386 + async closeTab(tabId, options = {}) {
387 + const tab = this.tabs.find((item) => item.tab_id === tabId);
388 + if (!tab) return;
389 + if (this.isTabDirty(tab) && options.confirm !== false) {
390 + this.beginCloseConfirmation("single", [tab.tab_id]);
391 + return;
392 + }
393 + await this.closeTabNow(tab, { save: options.save === true });
394 + },
395 +
396 + async closeTabNow(tab, options = {}) {
397 + if (!tab || this.loading) return false;
398 + const tabId = tab.tab_id;
399 + if (options.save === true && this.isTabDirty(tab)) {
400 + const saved = await this.saveTab(tab);
401 + if (!saved) return false;
402 + }
403 + try {
404 + if (tab.session_id) {
405 + await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null);
406 + }
407 + await callEditor("close", {
408 + session_id: tab.session_id || "",
409 + store_session_id: tab.store_session_id || "",
410 + file_id: tab.file_id || "",
411 + });
412 + } catch (error) {
413 + console.warn("Markdown close skipped", error);
414 + }
415 + this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
416 + if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) {
417 + this.pendingClose = null;
418 + }
419 + if (this.activeTabId === tabId) {
420 + this.session = null;
421 + this.activeTabId = "";
422 + this.editorText = "";
423 + this.dirty = false;
424 + this.ensureActiveTab();
425 + }
426 + this.ensureActiveTab();
427 + await this.refresh();
428 + return true;
429 + },
430 +
431 + async closeActiveFile() {
432 + if (!this.session || this.loading) return;
433 + await this.closeTab(this.session.tab_id);
434 + },
435 +
436 + async closeAllFiles(options = {}) {
437 + if (this.loading) return;
438 + const requestedIds = Array.isArray(options.tabIds) && options.tabIds.length
439 + ? options.tabIds
440 + : this.visibleTabs().map((tab) => tab.tab_id);
441 + const tabs = requestedIds.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean);
442 + if (!tabs.length) return;
443 +
444 + const dirtyTabs = tabs.filter((tab) => this.isTabDirty(tab));
445 + if (dirtyTabs.length && options.confirm !== false) {
446 + this.beginCloseConfirmation("all", tabs.map((tab) => tab.tab_id));
447 + return;
448 + }
449 +
450 + this.pendingClose = null;
451 + for (const tab of [...tabs]) {
452 + const current = this.tabs.find((item) => item.tab_id === tab.tab_id);
453 + if (!current) continue;
454 + const closed = await this.closeTabNow(current, {
455 + save: options.save === true && this.isTabDirty(current),
456 + });
457 + if (!closed) break;
458 + }
459 + },
460 +
461 + async save() {
462 + if (!this.session || this.saving || !this.isMarkdown()) return;
463 + this.syncEditorText();
464 + this.saving = true;
465 + this.error = "";
466 + try {
467 + let response;
468 + const payload = { session_id: this.session.session_id, text: this.editorText };
469 + try {
470 + response = await requestEditor("editor_save", payload, 10000);
471 + } catch (_socketError) {
472 + response = await callEditor("save", payload);
473 + }
474 + if (response?.ok === false) throw new Error(response.error || "Save failed.");
475 + const document = normalizeMarkdown(response.document || this.session.document || {});
476 + const updated = {
477 + ...this.session,
478 + text: this.editorText,
479 + dirty: false,
480 + document,
481 + path: document.path || this.session.path,
482 + file_id: document.file_id || this.session.file_id,
483 + version: document.version || response.version || this.session.version,
484 + };
485 + this.replaceActiveSession(updated);
486 + this.dirty = false;
487 + this.setMessage("Saved");
488 + await this.refresh();
489 + } catch (error) {
490 + this.error = error instanceof Error ? error.message : String(error);
491 + } finally {
492 + this.saving = false;
493 + }
494 + },
495 +
496 + async saveTab(tab) {
497 + if (!tab || this.saving || !this.isMarkdown(tab)) return false;
498 + if (this.isActiveTab(tab)) {
499 + this.syncEditorText();
500 + }
501 + this.saving = true;
502 + this.error = "";
503 + try {
504 + let response;
505 + const payload = {
506 + session_id: tab.session_id,
507 + text: this.isActiveTab(tab) ? this.editorText : String(tab.text || ""),
508 + };
509 + try {
510 + response = await requestEditor("editor_save", payload, 10000);
511 + } catch (_socketError) {
512 + response = await callEditor("save", payload);
513 + }
514 + if (response?.ok === false) throw new Error(response.error || "Save failed.");
515 + const document = normalizeMarkdown(response.document || tab.document || {});
516 + const updated = {
517 + ...tab,
518 + text: payload.text,
519 + dirty: false,
520 + document,
521 + path: document.path || tab.path,
522 + file_id: document.file_id || tab.file_id,
523 + version: document.version || response.version || tab.version,
524 + };
525 + this.replaceSession(tab, updated);
526 + if (this.isActiveTab(updated)) {
527 + this.dirty = false;
528 + }
529 + this.setMessage("Saved");
530 + await this.refresh();
531 + return true;
532 + } catch (error) {
533 + this.error = error instanceof Error ? error.message : String(error);
534 + return false;
535 + } finally {
536 + this.saving = false;
537 + }
538 + },
539 +
540 + async renameActiveFile() {
541 + if (!this.session || this.saving) return;
542 + const session = this.session;
543 + const path = session.path || session.document?.path || "";
544 + if (!path) {
545 + this.error = "This document does not have a file path to rename.";
546 + return;
547 + }
548 + const name = basename(path || session.title || "");
549 + const extension = extensionOf(name);
550 + await fileBrowserStore.openRenameModal(
551 + {
552 + name,
553 + path,
554 + is_dir: false,
555 + size: session.document?.size || 0,
556 + modified: session.document?.last_modified || "",
557 + type: "document",
558 + },
559 + {
560 + currentPath: parentPath(path),
561 + validateName: (newName) => {
562 + if (!extension) return true;
563 + return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
564 + },
565 + performRename: async ({ path: renamedPath }) => {
566 + const payload = {
567 + file_id: session.file_id || "",
568 + path: renamedPath,
569 + };
570 + if (this.isMarkdown(session)) {
571 + this.syncEditorText();
572 + payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
573 + }
574 + return await callEditor("renamed", payload);
575 + },
576 + onRenamed: async ({ path: renamedPath, response }) => {
577 + await this.handleActiveFileRenamed(session, renamedPath, response);
578 + },
579 + },
580 + );
581 + },
582 +
583 + async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
584 + const response = renameResponse || await callEditor("renamed", {
585 + file_id: session.file_id || "",
586 + path: renamedPath,
587 + });
588 + if (response?.ok === false) throw new Error(response.error || "Rename failed.");
589 +
590 + const document = normalizeMarkdown(response.document || session.document || {});
591 + const updated = {
592 + ...session,
593 + document,
594 + title: document.title || document.basename || basename(document.path),
595 + path: document.path || renamedPath,
596 + extension: document.extension || session.extension,
597 + file_id: document.file_id || session.file_id,
598 + version: document.version || response.version || session.version,
599 + text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
600 + dirty: false,
601 + };
602 + this.replaceSession(session, updated);
603 + this.dirty = false;
604 + this.setMessage("Renamed");
605 + await this.refresh();
606 + },
607 +
608 + replaceActiveSession(next) {
609 + if (!this.session) return;
610 + this.replaceSession(this.session, next);
611 + },
612 +
613 + replaceSession(previous, next) {
614 + const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id);
615 + if (wasActive) this.session = next;
616 + const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
617 + if (index >= 0) this.tabs.splice(index, 1, next);
618 + this.queueRender();
619 + },
620 +
621 + setMessage(value) {
622 + this.message = value;
623 + if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
624 + this._saveMessageTimer = globalThis.setTimeout(() => {
625 + this.message = "";
626 + this._saveMessageTimer = null;
627 + }, SAVE_MESSAGE_MS);
628 + },
629 +
630 + resetHistory(text) {
631 + this._history = [String(text || "")];
632 + this._historyIndex = 0;
633 + },
634 +
635 + pushHistory(text) {
636 + const value = String(text || "");
637 + if (this._history[this._historyIndex] === value) return;
638 + this._history = this._history.slice(0, this._historyIndex + 1);
639 + this._history.push(value);
640 + if (this._history.length > MAX_HISTORY) this._history.shift();
641 + this._historyIndex = this._history.length - 1;
642 + },
643 +
644 + undo() {
645 + if (this._historyIndex <= 0) return;
646 + this._historyIndex -= 1;
647 + this.applyEditorText(this._history[this._historyIndex], true);
648 + },
649 +
650 + redo() {
651 + if (this._historyIndex >= this._history.length - 1) return;
652 + this._historyIndex += 1;
653 + this.applyEditorText(this._history[this._historyIndex], true);
654 + },
655 +
656 + canUndo() {
657 + return this._historyIndex > 0;
658 + },
659 +
660 + canRedo() {
661 + return this._historyIndex < this._history.length - 1;
662 + },
663 +
664 + applyEditorText(text, markDirty = false) {
665 + this.editorText = String(text || "");
666 + if (this.session) {
667 + this.session.text = this.editorText;
668 + this.session.dirty = markDirty || this.session.dirty;
669 + }
670 + if (markDirty) this.markDirty();
671 + this.queueRender({ force: true, focus: true });
672 + },
673 +
674 + markDirty() {
675 + this.dirty = true;
676 + if (this.session) this.session.dirty = true;
677 + },
678 +
679 + onSourceInput() {
680 + this.markDirty();
681 + this.pushHistory(this.editorText);
682 + this.scheduleInputPush();
683 + },
684 +
685 + syncEditorText() {
686 + if (!this.session) return;
687 + this.session.text = this.editorText;
688 + },
689 +
690 + scheduleInputPush() {
691 + if (!this.session?.session_id || !this.isMarkdown()) return;
692 + if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
693 + this._inputTimer = globalThis.setTimeout(() => {
694 + this._inputTimer = null;
695 + this.flushInput();
696 + }, INPUT_PUSH_DELAY_MS);
697 + },
698 +
699 + flushInput() {
700 + if (!this.session?.session_id || !this.isMarkdown()) return;
701 + this.syncEditorText();
702 + requestEditor("editor_input", {
703 + session_id: this.session.session_id,
704 + text: this.editorText,
705 + }, 3000).catch(() => {});
706 + },
707 +
708 + format(command) {
709 + if (!this.session || !this.isMarkdown()) return;
710 + const textarea = this._root?.querySelector?.("[data-editor-source]");
711 + if (!textarea) return;
712 + const start = textarea.selectionStart || 0;
713 + const end = textarea.selectionEnd || start;
714 + const selected = this.editorText.slice(start, end);
715 + let replacement = selected;
716 + if (command === "bold") replacement = `**${selected || "text"}**`;
717 + if (command === "italic") replacement = `*${selected || "text"}*`;
718 + if (command === "list") replacement = (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
719 + if (command === "numbered") replacement = (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
720 + if (command === "table") replacement = "| Column | Value |\n| --- | --- |\n| | |";
721 + if (replacement === selected) return;
722 + this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
723 + this.onSourceInput();
724 + globalThis.requestAnimationFrame?.(() => {
725 + textarea.focus();
726 + textarea.selectionStart = start;
727 + textarea.selectionEnd = start + replacement.length;
728 + });
729 + },
730 +
731 + queueRender(options = {}) {
732 + if (options.focus) {
733 + this._pendingFocus = true;
734 + this._pendingFocusEnd = options.end !== false;
735 + this._focusAttempts = 0;
736 + }
737 + const render = () => {
738 + if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
739 + this._pendingFocus = false;
740 + this._focusAttempts = 0;
741 + } else if (this._pendingFocus && this._focusAttempts < 6) {
742 + this._focusAttempts += 1;
743 + globalThis.setTimeout(render, 45);
744 + }
745 + };
746 + if (globalThis.requestAnimationFrame) {
747 + globalThis.requestAnimationFrame(render);
748 + } else {
749 + globalThis.setTimeout(render, 0);
750 + }
751 + },
752 +
753 + focusEditor(options = {}) {
754 + if (!this.session || !this.isMarkdown()) return false;
755 + const source = this._root?.querySelector?.("[data-editor-source]");
756 + if (!source) return false;
757 + source.focus?.({ preventScroll: true });
758 + if (!editorContainsFocus(source)) return false;
759 + if (options.end !== false) placeCaretAtEnd(source);
760 + return true;
761 + },
762 +
763 + isMarkdown(tab = this.session) {
764 + const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
765 + return ext === "md";
766 + },
767 +
768 + hasActiveFile(tab = this.session) {
769 + return Boolean(tab && this.isMarkdown(tab));
770 + },
771 +
772 + visibleTabs() {
773 + return this.tabs.filter((tab) => this.hasActiveFile(tab));
774 + },
775 +
776 + defaultTitle(kind, fmt) {
777 + const date = new Date().toISOString().slice(0, 10);
778 + if (fmt === "md") return `Markdown ${date}`;
779 + return `Markdown ${date}`;
780 + },
781 +
782 + tabTitle(tab = {}) {
783 + tab = tab || {};
784 + return tab.title || tab.document?.basename || basename(tab.path);
785 + },
786 +
787 + tabLabel(tab = {}) {
788 + tab = tab || {};
789 + const title = this.tabTitle(tab);
790 + return tab.dirty ? `${title} unsaved` : title;
791 + },
792 +
793 + tabIcon(tab = {}) {
794 + tab = tab || {};
795 + const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
796 + if (ext === "md") return "article";
797 + return "draft";
798 + },
799 +
800 + async runNewMenuAction(action = "") {
801 + const normalized = String(action || "").trim().toLowerCase();
802 + if (normalized === "open") return await this.openFileBrowser();
803 + if (normalized === "markdown") return await this.create("document", "md");
804 + return null;
805 + },
806 +
807 + installHeaderNewMenu(header = null) {
808 + if (!header || header.querySelector(".editor-header-actions")) return () => {};
809 +
810 + const root = document.createElement("div");
811 + root.className = "editor-header-actions";
812 + root.innerHTML = `
813 + <button type="button" class="editor-header-new-button" aria-haspopup="menu" aria-expanded="false">
814 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
815 + <span>New</span>
816 + <span class="material-symbols-outlined editor-new-chevron" aria-hidden="true">expand_more</span>
817 + </button>
818 + <div class="editor-new-menu" role="menu" hidden>
819 + <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="open">
820 + <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
821 + <span>Open</span>
822 + </button>
823 + <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="markdown">
824 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
825 + <span>Markdown</span>
826 + </button>
827 + </div>
828 + `;
829 +
830 + const button = root.querySelector(".editor-header-new-button");
831 + const menu = root.querySelector(".editor-new-menu");
832 + const setOpen = (open) => {
833 + root.classList.toggle("is-open", open);
834 + button?.setAttribute("aria-expanded", open.toString());
835 + if (menu) menu.hidden = !open;
836 + };
837 + const onButtonClick = (event) => {
838 + event.preventDefault();
839 + event.stopPropagation();
840 + setOpen(!root.classList.contains("is-open"));
841 + };
842 + const onMarkdownClick = (event) => {
843 + if (!root.contains(event.target)) setOpen(false);
844 + };
845 + const onMarkdownKeydown = (event) => {
846 + if (event.key === "Escape") setOpen(false);
847 + };
848 +
849 + button?.addEventListener("click", onButtonClick);
850 + for (const item of root.querySelectorAll("[data-editor-new-action]")) {
851 + item.addEventListener("click", async (event) => {
852 + event.preventDefault();
853 + event.stopPropagation();
854 + const action = event.currentTarget?.dataset?.editorNewAction || "";
855 + setOpen(false);
856 + await this.runNewMenuAction(action);
857 + });
858 + }
859 + document.addEventListener("click", onMarkdownClick);
860 + document.addEventListener("keydown", onMarkdownKeydown);
861 +
862 + const firstHeaderAction = header.querySelector(".modal-close");
863 + if (firstHeaderAction) {
864 + firstHeaderAction.insertAdjacentElement("beforebegin", root);
865 + } else {
866 + header.appendChild(root);
867 + }
868 +
869 + setOpen(false);
870 + return () => {
871 + button?.removeEventListener("click", onButtonClick);
872 + document.removeEventListener("click", onMarkdownClick);
873 + document.removeEventListener("keydown", onMarkdownKeydown);
874 + root.remove();
875 + };
876 + },
877 +
878 + setupMarkdownModal(element = null) {
879 + const root = element || document.querySelector(".editor-panel");
880 + const inner = root?.closest?.(".modal-inner");
881 + const header = inner?.querySelector?.(".modal-header");
882 + if (!inner || !header || inner.dataset.editorModalReady === "1") return;
883 + inner.dataset.editorModalReady = "1";
884 + inner.classList.add("editor-modal");
885 + const cleanup = [];
886 + const closeButton = inner.querySelector(".modal-close");
887 + const focusButton = document.createElement("button");
888 + focusButton.type = "button";
889 + focusButton.className = "modal-dock-button editor-modal-focus-button";
890 + focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
891 + const updateFocusButton = (active) => {
892 + const label = active ? "Restore size" : "Focus mode";
893 + focusButton.setAttribute("aria-label", label);
894 + focusButton.setAttribute("title", label);
895 + focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
896 + };
897 + updateFocusButton(false);
898 + const onFocusClick = () => {
899 + const active = !inner.classList.contains("is-focus-mode");
900 + inner.classList.toggle("is-focus-mode", active);
901 + updateFocusButton(active);
902 + };
903 + focusButton.addEventListener("click", onFocusClick);
904 + if (closeButton) {
905 + closeButton.insertAdjacentElement("beforebegin", focusButton);
906 + } else {
907 + header.appendChild(focusButton);
908 + }
909 + cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
910 + cleanup.push(() => focusButton.remove());
911 +
912 + this._headerCleanup = () => {
913 + cleanup.splice(0).reverse().forEach((entry) => entry());
914 + delete inner.dataset.editorModalReady;
915 + inner.classList.remove("editor-modal", "is-focus-mode");
916 + };
917 + const menuCleanup = this.installHeaderNewMenu(header);
918 + const previousCleanup = this._headerCleanup;
919 + this._headerCleanup = () => {
920 + menuCleanup?.();
921 + previousCleanup?.();
922 + };
923 + },
924 +};
925 +
926 +export const store = createStore("editor", model);
plugins/_editor/webui/main.html new
+21
@@ -0,0 +1,21 @@
1 +<html
2 + class="surface-modal editor-modal modal-no-backdrop"
3 + data-surface-id="editor"
4 + data-surface-modal-path="/plugins/_editor/webui/main.html"
5 + data-surface-dock-title="Open Editor in surface"
6 + data-surface-dock-icon="dock_to_right"
7 + data-canvas-surface="editor"
8 + data-canvas-modal-path="/plugins/_editor/webui/main.html"
9 + data-canvas-dock-title="Open Editor in canvas"
10 + data-canvas-dock-icon="dock_to_right"
11 +>
12 +<head>
13 + <title>Editor</title>
14 + <script type="module">
15 + import { store } from "/plugins/_editor/webui/editor-store.js";
16 + </script>
17 +</head>
18 +<body class="editor-modal-body">
19 + <x-component path="/plugins/_editor/webui/editor-panel.html" mode="modal"></x-component>
20 +</body>
21 +</html>
plugins/_office/api/office_session.py
+17 -23
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from helpers.api import ApiHandler, Request
4 from plugins._desktop.helpers import desktop_session
5 -from plugins._office.helpers import document_store, libreoffice, markdown_sessions
5 +from plugins._office.helpers import document_store, libreoffice
6
7
8 class OfficeSession(ApiHandler):
@@ -28,7 +28,7 @@ class OfficeSession(ApiHandler):
28 doc = document_store.create_document(
29 kind=str(input.get("kind") or "document"),
30 title=str(input.get("title") or "Untitled"),
31 - fmt=str(input.get("format") or "md"),
31 + fmt=str(input.get("format") or "odt"),
32 content=str(input.get("content") or ""),
33 path=str(input.get("path") or ""),
34 context_id=context_id,
@@ -75,6 +75,19 @@ class OfficeSession(ApiHandler):
75
76 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
77 mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
78 + if str(doc.get("extension") or "").lower() == "md":
79 + return {
80 + "ok": True,
81 + "requires_editor": True,
82 + "file_id": doc["file_id"],
83 + "title": doc["basename"],
84 + "extension": doc["extension"],
85 + "path": doc["path"],
86 + "text": "",
87 + "document": _public_doc(doc),
88 + "version": document_store.item_version(doc),
89 + "mode": mode,
90 + }
91 if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
92 if input.get("open_in_desktop") is not True:
93 return {
@@ -119,29 +132,10 @@ class OfficeSession(ApiHandler):
132 "store_session_id": store_session["session_id"],
133 "mode": mode,
134 }
122 - store_session = document_store.create_session(
123 - doc["file_id"],
124 - user_id=str(input.get("user_id") or "agent-zero-user"),
125 - permission="write" if mode == "edit" else "read",
126 - origin=self._origin(request),
127 - )
128 - try:
129 - editor = markdown_sessions.get_manager().open(doc, sid="")
130 - except ValueError as exc:
131 - document_store.close_session(session_id=store_session["session_id"])
132 - return {"ok": False, "error": str(exc)}
133 - return {
134 - **editor,
135 - "store_session_id": store_session["session_id"],
136 - "session_id": editor["session_id"],
137 - "mode": mode,
138 - }
135 + return {"ok": False, "error": f".{doc.get('extension', '')} documents are not supported by LibreOffice."}
136
137 def _save(self, input: dict) -> dict:
141 - session_id = str(input.get("session_id") or "").strip()
142 - if not session_id:
143 - return {"ok": False, "error": "session_id is required."}
144 - return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
138 + return {"ok": False, "error": "Markdown saves use /plugins/_editor/editor_session."}
139
140 def _renamed(self, input: dict, context_id: str = "") -> dict:
141 file_id = str(input.get("file_id") or "").strip()
plugins/_office/api/ws_office.py
+53 -17
@@ -1,15 +1,17 @@
1 from __future__ import annotations
2
3 +from pathlib import Path
4 from typing import Any
5
6 from helpers.ws import WsHandler
7 from helpers.ws_manager import WsResult
7 -from plugins._office.helpers import document_store, markdown_sessions
8 +from plugins._desktop.helpers import desktop_session
9 +from plugins._office.helpers import document_store
10
11
12 class WsOffice(WsHandler):
13 async def on_disconnect(self, sid: str) -> None:
12 - markdown_sessions.get_manager().close_sid(sid)
14 + return None
15
16 async def process(self, event: str, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult | None:
17 if not event.startswith("office_"):
@@ -17,19 +19,12 @@ class WsOffice(WsHandler):
19 try:
20 if event == "office_open":
21 return self._open(data, sid)
20 - if event == "office_input":
21 - return markdown_sessions.get_manager().input(
22 - str(data.get("session_id") or ""),
23 - text=data.get("text") if "text" in data else None,
24 - patch=data.get("patch") if isinstance(data.get("patch"), dict) else None,
25 - )
26 - if event == "office_save":
27 - return markdown_sessions.get_manager().save(
28 - str(data.get("session_id") or ""),
29 - text=data.get("text") if "text" in data else None,
30 - )
31 - if event == "office_close":
32 - return markdown_sessions.get_manager().close(str(data.get("session_id") or ""))
22 + if event in {"office_input", "office_save", "office_close"}:
23 + return {
24 + "ok": False,
25 + "requires_editor": True,
26 + "error": "Markdown editing moved to /plugins/_editor.",
27 + }
28 except FileNotFoundError as exc:
29 return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
30 except Exception as exc:
@@ -53,8 +48,49 @@ class WsOffice(WsHandler):
48 doc = document_store.create_document(
49 kind=str(data.get("kind") or "document"),
50 title=str(data.get("title") or "Untitled"),
56 - fmt=str(data.get("format") or "md"),
51 + fmt=str(data.get("format") or "odt"),
52 content=str(data.get("content") or ""),
53 context_id=context_id,
54 )
60 - return markdown_sessions.get_manager().open(doc, sid=sid)
55 + ext = str(doc.get("extension") or "").lower()
56 + if ext == "md":
57 + return {
58 + "ok": True,
59 + "requires_editor": True,
60 + "file_id": doc["file_id"],
61 + "title": doc["basename"],
62 + "extension": doc["extension"],
63 + "path": doc["path"],
64 + "document": _public_doc(doc),
65 + "version": document_store.item_version(doc),
66 + }
67 + if ext in desktop_session.OFFICIAL_EXTENSIONS:
68 + return {
69 + "ok": True,
70 + "requires_desktop": True,
71 + "file_id": doc["file_id"],
72 + "title": doc["basename"],
73 + "extension": doc["extension"],
74 + "path": doc["path"],
75 + "document": _public_doc(doc),
76 + "version": document_store.item_version(doc),
77 + }
78 + return WsResult.error(
79 + code="UNSUPPORTED_OFFICE_DOCUMENT",
80 + message=f".{ext} documents are not supported by LibreOffice.",
81 + correlation_id=data.get("correlationId"),
82 + )
83 +
84 +
85 +def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
86 + return {
87 + "file_id": doc["file_id"],
88 + "path": document_store.display_path(doc["path"]),
89 + "basename": doc["basename"],
90 + "title": doc["basename"],
91 + "extension": doc["extension"],
92 + "size": doc["size"],
93 + "version": document_store.item_version(doc),
94 + "last_modified": doc["last_modified"],
95 + "exists": Path(doc["path"]).exists(),
96 + }
plugins/_office/extensions/python/message_loop_prompts_after/_55_include_office_canvas_context.py
+1 -13
@@ -2,20 +2,8 @@ from __future__ import annotations
2
3 from agent import LoopData
4 from helpers.extension import Extension
5 -from plugins._office.helpers import canvas_context
5
6
7 class IncludeOfficeCanvasContext(Extension):
8 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 - if not self.agent:
11 - return
12 -
13 - context = canvas_context.build_context()
14 - if not context:
15 - loop_data.extras_temporary.pop("office_canvas", None)
16 - return
17 -
18 - loop_data.extras_temporary["office_canvas"] = self.agent.read_prompt(
19 - "agent.extras.office_canvas.md",
20 - office_canvas=context,
21 - )
9 + loop_data.extras_temporary.pop("office_canvas", None)
plugins/_office/extensions/webui/lib/document-actions.js
+21 -2
@@ -1,7 +1,8 @@
1 import { showButtonFeedback } from "/components/messages/action-buttons/simple-action-buttons.js";
2 import { open as openSurface } from "/js/surfaces.js";
3
4 -const DESKTOP_FORMATS = ["md", "odt", "ods", "odp", "docx", "xlsx", "pptx"];
4 +const EDITOR_FORMATS = ["md"];
5 +const DESKTOP_FORMATS = ["odt", "ods", "odp", "docx", "xlsx", "pptx"];
6
7 function basename(path = "") {
8 const value = String(path || "").split("?")[0].split("#")[0];
@@ -104,10 +105,28 @@ export async function openDocumentInDesktop(document = {}) {
105 });
106 }
107
108 +export async function openDocumentInEditor(document = {}) {
109 + await openSurface("editor", {
110 + path: document.path || "",
111 + file_id: document.file_id || "",
112 + refresh: true,
113 + source: "message-action",
114 + });
115 +}
116 +
117 export async function openDocumentArtifact(document = {}) {
118 + if (usesEditor(document)) {
119 + await openDocumentInEditor(document);
120 + return;
121 + }
122 await openDocumentInDesktop(document);
123 }
124
125 +function usesEditor(doc = {}) {
126 + const format = String(doc.format || doc.extension || "").toLowerCase();
127 + return EDITOR_FORMATS.includes(format);
128 +}
129 +
130 function usesDesktop(doc = {}) {
131 const format = String(doc.format || doc.extension || "").toLowerCase();
132 return DESKTOP_FORMATS.includes(format);
@@ -118,7 +137,7 @@ function canvasActionTitle(doc = {}) {
137 if (["odt", "docx"].includes(format)) return "Open in canvas with Writer";
138 if (["ods", "xlsx"].includes(format)) return "Open in canvas with Calc";
139 if (["odp", "pptx"].includes(format)) return "Open in canvas with Impress";
121 - if (format === "md") return "Open Markdown in canvas";
140 + if (format === "md") return "Open Markdown in Editor";
141 return "Open in canvas";
142 }
143
plugins/_office/extensions/webui/set_messages_after_loop/auto-open-document-results.js
+32 -1
@@ -1,5 +1,6 @@
1 import { store as officeStore } from "/plugins/_office/webui/office-store.js";
2 import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
3 +import { store as editorStore } from "/plugins/_editor/webui/editor-store.js";
4 import { open as openSurface } from "/js/surfaces.js";
5
6 const SYNC_WINDOW_MS = 10 * 60 * 1000;
@@ -103,7 +104,7 @@ function isExplicitDocumentUiRequest(payload = {}) {
104 }
105
106 async function openDocumentUiFromResult(target = {}, payload = {}, document = {}) {
106 - await openSurface("desktop", {
107 + await openSurface(surfaceForDocument(payload, document), {
108 path: target.path || "",
109 file_id: target.file_id || "",
110 refresh: true,
@@ -121,6 +122,10 @@ function documentExtension(payload = {}, document = {}) {
122 ).toLowerCase();
123 }
124
125 +function surfaceForDocument(payload = {}, document = {}) {
126 + return documentExtension(payload, document) === "md" ? "editor" : "desktop";
127 +}
128 +
129 function isOfficeModalOpen() {
130 if (
131 globalThis.isModalOpen?.("/plugins/_office/webui/main.html")
@@ -141,11 +146,37 @@ function isDesktopSurfaceOpen() {
146 );
147 }
148
149 +function isEditorSurfaceOpen() {
150 + return Boolean(
151 + globalThis.document?.querySelector?.(
152 + '[data-surface-id="editor"] .editor-panel, .modal-inner[data-surface-id="editor"] .editor-panel, .modal-inner[data-canvas-surface="editor"] .editor-panel',
153 + ),
154 + );
155 +}
156 +
157 async function syncOpenDocumentSurfaces(document = {}) {
158 + if (documentExtension({}, document) === "md") {
159 + await syncOpenEditorSurface(document);
160 + return;
161 + }
162 await syncOpenDesktopCanvas(document);
163 await syncOpenOfficeModal(document);
164 }
165
166 +async function syncOpenEditorSurface(document = {}) {
167 + const editor = editorStore;
168 + if (!editor || !isEditorSurfaceOpen()) return false;
169 + if (!hasSameDocument(editor, document)) return false;
170 + if (isDirtySameDocument(editor, document)) return false;
171 + await editor.openSession?.({
172 + path: document.path || "",
173 + file_id: document.file_id || "",
174 + refresh: true,
175 + source: "tool-result-sync",
176 + });
177 + return true;
178 +}
179 +
180 async function syncOpenDesktopCanvas(document = {}) {
181 const desktop = desktopStore;
182 if (!desktop || !isDesktopSurfaceOpen()) return false;
plugins/_office/helpers/artifact_editor.py
+1 -1
@@ -271,7 +271,7 @@ def _looks_like_replace_operation(operation: str = "") -> bool:
271
272 def _refresh_open_editor_sessions(file_id: str) -> None:
273 try:
274 - from plugins._office.helpers import markdown_sessions
274 + from plugins._editor.helpers import markdown_sessions
275
276 markdown_sessions.get_manager().refresh_document(file_id)
277 except Exception:
plugins/_office/helpers/document_store.py
+1 -1
@@ -492,7 +492,7 @@ def read_text_for_editor(doc: dict[str, Any]) -> str:
492
493
494 def write_markdown(file_id: str, content: str) -> dict[str, Any]:
495 - return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="office:markdown")
495 + return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="editor:markdown")
496
497
498 def replace_document_bytes(
plugins/_office/helpers/markdown_sessions.py
+7 -154
@@ -1,157 +1,10 @@
1 -from __future__ import annotations
1 +"""Compatibility shim for the Markdown session manager.
2
3 -import time
4 -import uuid
5 -from dataclasses import dataclass, field
6 -from pathlib import Path
7 -from typing import Any
3 +The native Markdown editor now lives in the `_editor` builtin plugin. Keep this
4 +module as a narrow import bridge for older extension code that has not yet moved
5 +its import path, but do not add Office-owned Markdown behavior here.
6 +"""
7
9 -from plugins._office.helpers import document_store
8 +from plugins._editor.helpers.markdown_sessions import MarkdownSession, MarkdownSessionManager, get_manager
9
11 -
12 -@dataclass
13 -class MarkdownSession:
14 - session_id: str
15 - file_id: str
16 - sid: str
17 - extension: str
18 - path: str
19 - title: str
20 - text: str = ""
21 - opened_at: float = field(default_factory=time.time)
22 - updated_at: float = field(default_factory=time.time)
23 -
24 -
25 -class MarkdownSessionManager:
26 - """Owns source-editor sessions for Markdown documents."""
27 -
28 - def __init__(self) -> None:
29 - self._sessions: dict[str, MarkdownSession] = {}
30 -
31 - def open(self, doc: dict[str, Any], sid: str = "") -> dict[str, Any]:
32 - ext = str(doc["extension"]).lower()
33 - if ext != "md":
34 - raise ValueError(f"Canvas editing is only available for Markdown. Open .{ext} files in the Desktop.")
35 -
36 - session = MarkdownSession(
37 - session_id=uuid.uuid4().hex,
38 - file_id=doc["file_id"],
39 - sid=sid,
40 - extension=ext,
41 - path=doc["path"],
42 - title=doc["basename"],
43 - text=document_store.read_text_for_editor(doc),
44 - )
45 - self._sessions[session.session_id] = session
46 - return self._payload(session, doc)
47 -
48 - def input(self, session_id: str, text: str | None = None, patch: dict[str, Any] | None = None) -> dict[str, Any]:
49 - session = self._require(session_id)
50 - if text is not None:
51 - session.text = str(text)
52 - elif patch:
53 - session.text = _apply_text_patch(session.text, patch)
54 - session.updated_at = time.time()
55 - return {"ok": True, "session_id": session.session_id}
56 -
57 - def save(self, session_id: str, text: str | None = None) -> dict[str, Any]:
58 - session = self._require(session_id)
59 - if text is not None:
60 - session.text = str(text)
61 -
62 - updated = document_store.write_markdown(session.file_id, session.text)
63 - session.updated_at = time.time()
64 - session.path = updated["path"]
65 - session.title = updated["basename"]
66 - return {
67 - "ok": True,
68 - "document": _public_doc(updated),
69 - "version": document_store.item_version(updated),
70 - }
71 -
72 - def refresh_document(self, file_id: str) -> dict[str, Any]:
73 - normalized = str(file_id or "").strip()
74 - if not normalized:
75 - return {"ok": True, "refreshed": 0, "sessions": []}
76 - try:
77 - doc = document_store.get_document(normalized)
78 - except Exception:
79 - return {"ok": False, "refreshed": 0, "sessions": []}
80 - if str(doc.get("extension") or "").lower() != "md":
81 - return {"ok": True, "refreshed": 0, "sessions": []}
82 -
83 - refreshed: list[str] = []
84 - for session in self._sessions.values():
85 - if session.file_id != normalized:
86 - continue
87 - session.text = document_store.read_text_for_editor(doc)
88 - session.path = doc["path"]
89 - session.title = doc["basename"]
90 - session.updated_at = time.time()
91 - refreshed.append(session.session_id)
92 - return {"ok": True, "refreshed": len(refreshed), "sessions": refreshed}
93 -
94 - def close(self, session_id: str) -> dict[str, Any]:
95 - session = self._sessions.pop(str(session_id or ""), None)
96 - if not session:
97 - return {"ok": True, "closed": 0}
98 - return {"ok": True, "closed": 1, "session_id": session_id}
99 -
100 - def close_sid(self, sid: str) -> int:
101 - doomed = [session_id for session_id, session in self._sessions.items() if session.sid == sid]
102 - for session_id in doomed:
103 - self._sessions.pop(session_id, None)
104 - return len(doomed)
105 -
106 - def _payload(self, session: MarkdownSession, doc: dict[str, Any]) -> dict[str, Any]:
107 - return {
108 - "ok": True,
109 - "session_id": session.session_id,
110 - "file_id": session.file_id,
111 - "title": session.title,
112 - "extension": session.extension,
113 - "path": session.path,
114 - "text": session.text,
115 - "document": _public_doc(doc),
116 - "version": document_store.item_version(doc),
117 - }
118 -
119 - def _require(self, session_id: str) -> MarkdownSession:
120 - normalized = str(session_id or "").strip()
121 - session = self._sessions.get(normalized)
122 - if not session:
123 - raise FileNotFoundError(f"Editor session not found: {normalized}")
124 - return session
125 -
126 -
127 -def get_manager() -> MarkdownSessionManager:
128 - global _manager
129 - try:
130 - return _manager
131 - except NameError:
132 - _manager = MarkdownSessionManager()
133 - return _manager
134 -
135 -
136 -def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
137 - return {
138 - "file_id": doc["file_id"],
139 - "path": document_store.display_path(doc["path"]),
140 - "basename": doc["basename"],
141 - "extension": doc["extension"],
142 - "size": doc["size"],
143 - "version": document_store.item_version(doc),
144 - "last_modified": doc["last_modified"],
145 - "exists": Path(doc["path"]).exists(),
146 - }
147 -
148 -
149 -def _apply_text_patch(text: str, patch: dict[str, Any]) -> str:
150 - if "content" in patch:
151 - return str(patch.get("content") or "")
152 - start = int(patch.get("start") or 0)
153 - end = int(patch.get("end") if patch.get("end") is not None else start)
154 - replacement = str(patch.get("text") or "")
155 - start = max(0, min(len(text), start))
156 - end = max(start, min(len(text), end))
157 - return text[:start] + replacement + text[end:]
10 +__all__ = ["MarkdownSession", "MarkdownSessionManager", "get_manager"]
plugins/_office/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _office
2 title: LibreOffice
3 -description: Markdown writing and ODF-first LibreOffice document artifacts.
3 +description: ODF-first LibreOffice document artifacts and compatibility document tooling.
4 version: "0.1"
5 settings_sections:
6 - developer
plugins/_office/prompts/agent.system.tool.document_artifact.md
+1
@@ -6,6 +6,7 @@ actions: create open read edit inspect export version_history restore_version st
6 common args: action kind title format content path file_id operation find replace
7 optional UI intent args: open_in_canvas open_in_desktop
8 create/read/edit results save or update artifacts only; they do not open a surface automatically unless the user explicitly asks to open the document UI
9 +Markdown opens in the Editor surface; ODT/ODS/ODP/DOCX/XLSX/PPTX open in Desktop
10 use action `open`, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop
11 for action `edit`, use operation and put append/prepend/set text in `content` (example: operation `append_text`, content "new line")
12 after create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels like "Open document" or "Download file"
plugins/_office/skills/document-artifacts/SKILL.md
+5 -5
@@ -24,9 +24,9 @@ allowed_tools:
24
25 # Document Artifacts
26
27 -Use `document_artifact` for substantial deliverables that should remain editable in the custom document editor or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for OOXML compatibility, provides an existing file in that format, or needs that compatibility format.
27 +Use `document_artifact` for substantial deliverables that should remain editable in the Markdown Editor surface or LibreOffice Desktop. Markdown remains the default for ordinary writing, notes, reports, briefs, and drafts when no binary office file is needed. For LibreOffice office files, ODF is first-class: use ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only when the user explicitly asks for OOXML compatibility, provides an existing file in that format, or needs that compatibility format.
28
29 -The document UI and Desktop are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open a document modal or Desktop surface automatically if the user has not asked for that UI. Use the `open` action, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop. After create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
29 +The Editor and Desktop surfaces are user-owned. Creating, reading, or editing an artifact must save the file and update its state, but it must not open Editor or Desktop automatically if the user has not asked for that UI. Use the `open` action, `open_in_canvas: true`, or `open_in_desktop: true` only when the user explicitly asks to open the document/editor/Desktop. After create/edit, answer briefly with what changed and the saved path when useful; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
30
31 For format-specific work, prefer the matching skill when available:
32
@@ -160,10 +160,10 @@ Arguments:
160 - Prefer `file_id` from document context or prior tool output; use `path` when that is all you have.
161 - Use `read` before editing unless the current saved content is already known.
162 - Do not create an artifact for tiny one-shot edits or answers the agent can finish cleanly in chat or by directly editing the file.
163 -- For document-style writing requests with no requested binary format, create Markdown and let the custom Markdown editor be the primary interactive editor.
163 +- For document-style writing requests with no requested binary format, create Markdown and let the Editor surface be the primary interactive editor.
164 - For spreadsheet or presentation file requests with no OOXML compatibility requirement, create ODS or ODP.
165 - The Desktop runtime may be warmed during Agent Zero startup, but visible Desktop surface use remains opt-in. Treat LibreOffice GUI work as appropriate for explicit GUI requests, binary Office visual polish, or final layout inspection.
166 -- Never open a document modal or Desktop surface automatically from a tool result. If the user has not opened it, leave the saved artifact available through the normal UI affordance.
166 +- Never open Editor or Desktop automatically from a tool result. If the user has not opened it, leave the saved artifact available through the normal UI affordance.
167 - Use native `create_chart` for embedded spreadsheet charts. Reach for Python/code execution only when the requested chart behavior is not supported by the tool.
168 -- Use `edit` for precise saved changes; use the document editor or Desktop for human/manual layout polish.
168 +- Use `edit` for precise saved changes; use Editor for Markdown polish and Desktop for binary Office visual polish.
169 - Direct edits update version history and refresh the document UI on edit/open results.
plugins/_office/skills/markdown-documents/SKILL.md
+2 -2
@@ -20,7 +20,7 @@ allowed_tools:
20
21 Markdown is the default document format for normal writing, notes, reports, briefs, drafts, and collaborative text work unless the user explicitly asks for a binary office file. When they do ask for a LibreOffice office file, prefer ODF: ODT for Writer, ODS for Spreadsheet/Calc, and ODP for Presentation/Impress. Use DOCX, XLSX, or PPTX only for explicit OOXML compatibility.
22
23 -The document editor is user-owned UI. Create or update the saved Markdown artifact, but never open the document modal automatically. Keep the final response to the saved/updated result and path; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
23 +The Editor surface is user-owned UI. Create or update the saved Markdown artifact, but never open the Editor automatically. Keep the final response to the saved/updated result and path; do not write faux UI action labels such as "Open document" or "Download file", and do not add a note saying the canvas was not opened automatically unless the user explicitly asks about UI behavior.
24
25 ## Workflow
26
@@ -49,4 +49,4 @@ Practical rules:
49 - Prefer Markdown over ODT/DOCX for writing unless a binary Writer/Word file is explicitly needed.
50 - Keep agent-only cleanup simple: if the user asks to fix a typo, update the file and finish; do not force a document-editor workflow.
51 - Use clear headings and Markdown tables when they improve editability.
52 -- The custom Markdown editor is available through the response file card.
52 +- The Markdown Editor surface is available through the response file card.
plugins/_office/tools/document_artifact.py
+3 -3
@@ -254,7 +254,7 @@ class DocumentArtifact(Tool):
254 open_in_desktop=open_in_desktop,
255 ) if doc else {
256 "_tool_name": self.name,
257 - "canvas_surface": "office",
257 + "canvas_surface": "desktop",
258 "action": action,
259 "open_in_canvas": bool(open_in_canvas),
260 "open_in_desktop": bool(open_in_desktop),
@@ -272,14 +272,14 @@ class DocumentArtifact(Tool):
272 if not doc:
273 return {
274 "_tool_name": self.name,
275 - "canvas_surface": "office",
275 + "canvas_surface": "desktop",
276 "action": action,
277 "open_in_canvas": bool(open_in_canvas),
278 "open_in_desktop": bool(open_in_desktop),
279 }
280 return {
281 "_tool_name": self.name,
282 - "canvas_surface": "office",
282 + "canvas_surface": "editor" if doc["extension"] == "md" else "desktop",
283 "action": action,
284 "open_in_canvas": bool(open_in_canvas),
285 "open_in_desktop": bool(open_in_desktop),
plugins/_office/webui/office-panel.html
+1 -86
@@ -54,67 +54,18 @@
54 </div>
55 </div>
56
57 - <div class="office-toolbar" x-show="$store.office.session && $store.office.isMarkdown()" style="display: none;">
58 - <div class="office-toolbar-row">
59 - <div class="office-tool-group office-editor-tools">
60 - <button type="button" class="office-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.office.canUndo()" @click="$store.office.undo()">
61 - <span class="material-symbols-outlined">undo</span>
62 - </button>
63 - <button type="button" class="office-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.office.canRedo()" @click="$store.office.redo()">
64 - <span class="material-symbols-outlined">redo</span>
65 - </button>
66 - <button type="button" class="office-icon-button" title="Bold" aria-label="Bold" @click="$store.office.format('bold')">
67 - <span class="material-symbols-outlined">format_bold</span>
68 - </button>
69 - <button type="button" class="office-icon-button" title="Italic" aria-label="Italic" @click="$store.office.format('italic')">
70 - <span class="material-symbols-outlined">format_italic</span>
71 - </button>
72 - <button type="button" class="office-icon-button" title="List" aria-label="List" @click="$store.office.format('list')">
73 - <span class="material-symbols-outlined">format_list_bulleted</span>
74 - </button>
75 - <button type="button" class="office-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.office.format('numbered')">
76 - <span class="material-symbols-outlined">format_list_numbered</span>
77 - </button>
78 - <button type="button" class="office-icon-button" title="Table" aria-label="Table" @click="$store.office.format('table')">
79 - <span class="material-symbols-outlined">table</span>
80 - </button>
81 - </div>
82 - <span class="office-toolbar-spacer"></span>
83 - </div>
84 - </div>
85 -
57 <div class="office-state-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
58 <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
59 <span x-text="$store.office.error || $store.office.message || 'Working'"></span>
60 </div>
61
91 - <div class="office-body" :class="{ 'is-source': $store.office.isMarkdown() }">
92 - <div class="office-editor-wrap" x-show="$store.office.session" style="display: none;">
93 - <div class="office-editor-scroll is-source" @click.self="$store.office.focusEditor()">
94 - <textarea
95 - class="office-source-editor"
96 - data-office-source
97 - aria-label="Markdown source"
98 - x-show="$store.office.isMarkdown()"
99 - x-model="$store.office.editorText"
100 - @input="$store.office.onSourceInput()"
101 - @blur="$store.office.flushInput()"
102 - spellcheck="true"
103 - style="display: none;"
104 - ></textarea>
105 - </div>
106 - </div>
107 -
62 + <div class="office-body">
63 <div class="office-empty" x-show="!$store.office.session && !$store.office.loading" style="display: none;">
64 <div class="office-empty-actions">
65 <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('open')">
66 <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
67 <span class="office-button-label">Open</span>
68 </button>
114 - <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('markdown')">
115 - <span class="material-symbols-outlined" aria-hidden="true">article</span>
116 - <span class="office-button-label">Markdown</span>
117 - </button>
69 <button type="button" class="office-icon-button office-command-button" @click="$store.office.runNewMenuAction('writer')">
70 <span class="material-symbols-outlined" aria-hidden="true">description</span>
71 <span class="office-button-label">Writer</span>
@@ -272,15 +223,6 @@
223 background: var(--color-surface);
224 }
225
275 - .office-toolbar-row {
276 - display: flex;
277 - align-items: center;
278 - gap: 8px;
279 - width: 100%;
280 - min-width: 0;
281 - }
282 -
283 - .office-tool-group,
226 .office-empty-actions {
227 display: flex;
228 align-items: center;
@@ -288,10 +230,6 @@
230 gap: 6px;
231 }
232
291 - .office-toolbar-spacer {
292 - flex: 1 1 auto;
293 - }
294 -
233 .office-state-line {
234 font-size: 13px;
235 color: var(--color-muted);
@@ -303,29 +241,6 @@
241 min-height: 0;
242 }
243
306 - .office-editor-wrap,
307 - .office-editor-scroll {
308 - display: flex;
309 - flex: 1 1 auto;
310 - min-width: 0;
311 - min-height: 0;
312 - }
313 -
314 - .office-source-editor {
315 - flex: 1 1 auto;
316 - width: 100%;
317 - height: 100%;
318 - min-width: 0;
319 - min-height: 0;
320 - resize: none;
321 - border: 0;
322 - outline: none;
323 - padding: 16px;
324 - background: var(--color-background);
325 - color: var(--color-text);
326 - font: 14px/1.55 var(--font-mono, monospace);
327 - }
328 -
244 .office-empty {
245 display: flex;
246 flex: 1 1 auto;
plugins/_office/webui/office-store.js
+16 -6
@@ -204,7 +204,7 @@ const model = {
204 },
205
206 async create(kind = "document", format = "") {
207 - const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "md")).toLowerCase();
207 + const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase();
208 const title = this.defaultTitle(kind, fmt);
209 await this.openSession({
210 action: "create",
@@ -243,6 +243,18 @@ const model = {
243 this.error = response.error || "Document could not be opened.";
244 return null;
245 }
246 + if (response?.requires_editor) {
247 + const document = normalizeDocument(response.document || response);
248 + this.setMessage(`${documentLabel(document)} opens in Editor.`);
249 + await openSurface("editor", {
250 + path: document.path || response.path || "",
251 + file_id: document.file_id || response.file_id || "",
252 + refresh: true,
253 + source: "office-editor-handoff",
254 + });
255 + await this.refresh();
256 + return response;
257 + }
258 if (response?.requires_desktop || this.isDesktopDocument(response)) {
259 const document = normalizeDocument(response.document || response);
260 this.setMessage(`${documentLabel(document)} is ready. Use Open in Desktop to edit it.`);
@@ -650,7 +662,9 @@ const model = {
662 async runNewMenuAction(action = "") {
663 const normalized = String(action || "").trim().toLowerCase();
664 if (normalized === "open") return await this.openFileBrowser();
653 - if (normalized === "markdown") return await this.create("document", "md");
665 + if (normalized === "markdown") {
666 + return await openSurface("editor", { source: "office-editor-handoff" });
667 + }
668 if (normalized === "writer") return await this.create("document", "odt");
669 if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods");
670 if (normalized === "presentation") return await this.create("presentation", "odp");
@@ -673,10 +687,6 @@ const model = {
687 <span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
688 <span>Open</span>
689 </button>
676 - <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="markdown">
677 - <span class="material-symbols-outlined" aria-hidden="true">article</span>
678 - <span>Markdown</span>
679 - </button>
690 <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer">
691 <span class="material-symbols-outlined" aria-hidden="true">description</span>
692 <span>Writer</span>
tests/test_office_canvas_setup.py
+115 -6
@@ -40,8 +40,10 @@ def test_modals_are_generic_and_surfaces_own_live_surface_paths():
40 assert "closeSurfaceGroupModals" in surfaces_js
41 assert 'id: "browser"' in surfaces_js
42 assert 'id: "desktop"' in surfaces_js
43 + assert 'id: "editor"' in surfaces_js
44 assert "/plugins/_browser/webui/main.html" in surfaces_js
45 assert "/plugins/_desktop/webui/main.html" in surfaces_js
46 + assert "/plugins/_editor/webui/main.html" in surfaces_js
47 assert "LEGACY_SURFACE_IDS" in surfaces_js
48 assert '["office", "desktop"]' in surfaces_js
49 assert "htmlDataset.surfaceId" in surfaces_js
@@ -81,6 +83,25 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
83 )
84 right_canvas_css = read("webui", "components", "canvas", "right-canvas.css")
85 desktop_web_panel = read("plugins", "_desktop", "webui", "desktop-panel.html")
86 + editor_register = read(
87 + "plugins",
88 + "_editor",
89 + "extensions",
90 + "webui",
91 + "right_canvas_register_surfaces",
92 + "register-editor.js",
93 + )
94 + editor_panel = read(
95 + "plugins",
96 + "_editor",
97 + "extensions",
98 + "webui",
99 + "right-canvas-panels",
100 + "editor-panel.html",
101 + )
102 + editor_main = read("plugins", "_editor", "webui", "main.html")
103 + editor_web_panel = read("plugins", "_editor", "webui", "editor-panel.html")
104 + editor_store = read("plugins", "_editor", "webui", "editor-store.js")
105
106 assert 'await callJsExtensions("surfaces_register", this);' in canvas_store
107 assert 'await callJsExtensions("right_canvas_register_surfaces", this);' in canvas_store
@@ -89,13 +110,31 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
110 assert "const saved = migratePersistedSurfaceState(JSON.parse" in canvas_store
111 assert 'id: "desktop"' in desktop_register
112 assert 'modalPath: "/plugins/_desktop/webui/main.html"' in desktop_register
113 + assert 'id: "editor"' in editor_register
114 + assert 'title: "Editor"' in editor_register
115 + assert 'order: 30' in editor_register
116 + assert 'modalPath: "/plugins/_editor/webui/main.html"' in editor_register
117 assert 'data-surface-id="desktop"' in desktop_panel
118 assert "isSurfaceVisible('desktop')" in desktop_panel
119 + assert 'data-surface-id="editor"' in editor_panel
120 + assert "isSurfaceVisible('editor')" in editor_panel
121 + assert 'data-surface-id="editor"' in editor_main
122 + assert 'data-surface-modal-path="/plugins/_editor/webui/main.html"' in editor_main
123 + assert "editor-source-editor" in editor_web_panel
124 + assert "data-editor-source" in editor_web_panel
125 + assert "editor-tabs" in editor_web_panel
126 + assert "editor-close-confirm" in editor_web_panel
127 + assert "Save &amp; Close" in editor_web_panel
128 + assert "Close All" in editor_web_panel
129 + assert "closeAllFiles" in editor_store
130 + assert "confirmPendingClose" in editor_store
131 + assert "globalThis.confirm" not in editor_store
132 assert "right-canvas-desktop-actions" in desktop_new_menu
133 assert "isSurfaceActive('desktop')" in desktop_new_menu
134 assert "runNewMenuAction('writer')" in desktop_new_menu
135 assert "runNewMenuAction('spreadsheet')" in desktop_new_menu
136 assert "runNewMenuAction('presentation')" in desktop_new_menu
137 + assert "runNewMenuAction('markdown')" not in desktop_new_menu
138 assert ".right-canvas-header" in right_canvas_css
139 assert "overflow: visible;" in right_canvas_css
140 assert ".right-canvas-toolbar" in right_canvas_css
@@ -139,8 +178,10 @@ def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop
178 assert "modal-no-backdrop" not in office_modal
179 assert "data-canvas-surface" not in office_modal
180
142 - assert "office-source-editor" in office_panel
143 - assert "data-office-source" in office_panel
181 + assert "office-source-editor" not in office_panel
182 + assert "data-office-source" not in office_panel
183 + assert "runNewMenuAction('markdown')" not in office_panel
184 + assert 'data-office-new-action="markdown"' not in office_store
185 assert "openRenameModal" in office_store
186 assert "office_save" in office_store
187 assert 'callOffice("renamed"' in office_store
@@ -166,9 +207,10 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
207
208 assert "virtual_desktop_routes.install_route_hooks()" in desktop_startup
209 assert 'action in {"open_document", "document"}' in desktop_api
169 - assert "markdown_sessions" in desktop_api
210 assert 'if ext == "md":' in desktop_api
171 - assert "return self._open_markdown(doc, input, request)" in desktop_api
211 + assert "Markdown documents use the Editor surface." in desktop_api
212 + assert "return self._open_markdown(doc, input, request)" not in desktop_api
213 + assert "markdown_sessions" not in desktop_api
214 assert '"status": desktop.get("status") or {}' in desktop_api
215 assert 'callJsonApi("/plugins/_desktop/desktop_session"' in desktop_store
216 assert 'callDesktop("open_document"' in desktop_store
@@ -240,7 +282,8 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
282 document_tool = read("plugins", "_office", "tools", "document_artifact.py")
283 office_api = read("plugins", "_office", "api", "office_session.py")
284
243 - assert 'openSurface("desktop"' in auto_open
285 + assert 'openSurface(surfaceForDocument' in auto_open
286 + assert 'return documentExtension(payload, document) === "md" ? "editor" : "desktop";' in auto_open
287 assert "isExplicitDocumentUiRequest(payload)" in auto_open
288 assert 'action === "open"' in auto_open
289 assert "open_in_canvas" in auto_open
@@ -252,6 +295,9 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
295 assert "isOfficeCanvas" not in auto_open
296 assert "officeStore" in auto_open
297 assert "desktopStore" in auto_open
298 + assert "editorStore" in auto_open
299 + assert "syncOpenEditorSurface" in auto_open
300 + assert "isEditorSurfaceOpen" in auto_open
301 assert "syncOpenDesktopCanvas" in auto_open
302 assert "syncOpenOfficeModal" in auto_open
303 assert "isDesktopSurfaceOpen" in auto_open
@@ -283,7 +329,10 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
329 assert "refreshResponseFileActions" in response_cards
330 assert "parseStoredDocuments" in response_cards
331 assert "openDocumentInDesktop" in document_actions
332 + assert "openDocumentInEditor" in document_actions
333 assert "openDocumentArtifact" in document_actions
334 + assert 'await openSurface("editor"' in document_actions
335 + assert "await openDocumentInEditor(document);" in document_actions
336 assert "await openDocumentInDesktop(document);" in document_actions
337 assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in document_actions
338 assert 'ensureModalOpen("/plugins/_office/webui/main.html")' not in auto_open
@@ -294,10 +343,12 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
343 assert "Details" not in response_cards
344 assert "/api/download_work_dir_file" in document_actions
345 assert 'openSurface("desktop"' in document_actions
346 + assert 'openSurface("editor"' in document_actions
347 assert "Open in canvas with Writer" in document_actions
348 assert "Open in canvas with Calc" in document_actions
349 assert "Open in canvas with Impress" in document_actions
300 - assert '"md", "odt", "ods", "odp", "docx", "xlsx", "pptx"' in document_actions
350 + assert 'const EDITOR_FORMATS = ["md"]' in document_actions
351 + assert 'const DESKTOP_FORMATS = ["odt", "ods", "odp", "docx", "xlsx", "pptx"]' in document_actions
352 assert ".document-file-card" in messages_css
353 assert ".document-response-file-cards" in messages_css
354 assert ".document-file-action-label" not in messages_css
@@ -307,6 +358,64 @@ def test_document_artifacts_only_open_desktop_from_explicit_document_ui_requests
358 assert '"open_in_desktop": bool(open_in_desktop)' in document_tool
359 assert '"requires_desktop": True' in office_api
360 assert 'input.get("open_in_desktop") is not True' in office_api
361 + assert '"requires_editor": True' in office_api
362 +
363 +
364 +def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
365 + editor_plugin = PROJECT_ROOT / "plugins" / "_editor"
366 + assert (editor_plugin / "plugin.yaml").exists()
367 + assert (editor_plugin / "api" / "editor_session.py").exists()
368 + assert (editor_plugin / "api" / "ws_editor.py").exists()
369 +
370 + editor_session = read("plugins", "_editor", "helpers", "markdown_sessions.py")
371 + editor_api = read("plugins", "_editor", "api", "editor_session.py")
372 + editor_ws = read("plugins", "_editor", "api", "ws_editor.py")
373 + editor_context = read("plugins", "_editor", "helpers", "open_files_context.py")
374 + office_ws = read("plugins", "_office", "api", "ws_office.py")
375 + office_markdown_sessions = read("plugins", "_office", "helpers", "markdown_sessions.py")
376 + editor_extras = read(
377 + "plugins",
378 + "_editor",
379 + "extensions",
380 + "python",
381 + "message_loop_prompts_after",
382 + "_55_include_editor_open_files.py",
383 + )
384 + desktop_context = read(
385 + "plugins",
386 + "_desktop",
387 + "extensions",
388 + "python",
389 + "message_loop_prompts_after",
390 + "_55_include_desktop_state.py",
391 + )
392 + office_context = read(
393 + "plugins",
394 + "_office",
395 + "extensions",
396 + "python",
397 + "message_loop_prompts_after",
398 + "_55_include_office_canvas_context.py",
399 + )
400 +
401 + assert "context_id: str" in editor_session
402 + assert "self._active_by_context" in editor_session
403 + assert "def list_open" in editor_session
404 + assert "session.context_id == context_id" in editor_session
405 + assert "dirty" in editor_session
406 + assert "active" in editor_session
407 + assert 'action == "list"' in editor_api
408 + assert 'action == "activate"' in editor_api
409 + assert 'event == "editor_activate"' in editor_ws
410 + assert "[EDITOR OPEN FILES]" in read("plugins", "_editor", "prompts", "agent.extras.editor_open_files.md")
411 + assert "Content is omitted" in editor_context
412 + assert "self.agent.context.id" in editor_extras
413 + assert "editor_open_files" in editor_extras
414 + assert "desktop_state" in desktop_context
415 + assert 'pop("office_canvas"' in office_context
416 + assert "Markdown editing moved to /plugins/_editor." in office_ws
417 + assert "from plugins._office.helpers import document_store, markdown_sessions" not in office_ws
418 + assert "from plugins._editor.helpers.markdown_sessions import" in office_markdown_sessions
419
420
421 def test_office_and_desktop_skills_are_rehomed_and_renamed():
tests/test_office_document_store.py
+40 -4
@@ -21,12 +21,15 @@ from helpers import system_packages
21 from plugins._office import hooks
22 from plugins._desktop import hooks as desktop_hooks
23 from plugins._desktop.helpers import desktop_session
24 +from plugins._editor.helpers import (
25 + markdown_sessions as editor_markdown_sessions,
26 + open_files_context,
27 +)
28 from plugins._office.helpers import (
29 artifact_editor,
30 canvas_context,
31 document_store,
32 libreoffice,
29 - markdown_sessions,
33 )
34
35
@@ -51,6 +54,12 @@ def office_state(tmp_path, monkeypatch):
54 )
55 monkeypatch.setattr(document_store, "_settings", lambda: settings_helpers)
56 monkeypatch.setattr(document_store, "_projects", lambda: project_helpers)
57 + monkeypatch.setattr(
58 + editor_markdown_sessions,
59 + "_manager",
60 + editor_markdown_sessions.MarkdownSessionManager(),
61 + raising=False,
62 + )
63
64 workdir.mkdir(parents=True, exist_ok=True)
65 documents.mkdir(parents=True, exist_ok=True)
@@ -403,6 +412,33 @@ def test_sessions_and_canvas_context_are_neutral(office_state):
412 assert document_store.get_open_documents() == []
413
414
415 +def test_editor_open_files_are_scoped_to_active_context(office_state):
416 + first = document_store.create_document("document", "First Editor Note", "md", "First private body.")
417 + second = document_store.create_document("document", "Second Editor Note", "md", "Second private body.")
418 + manager = editor_markdown_sessions.get_manager()
419 +
420 + first_session = manager.open(first, context_id="ctx-a")
421 + second_session = manager.open(second, context_id="ctx-b")
422 + manager.input(first_session["session_id"], text="Unsaved ctx-a text")
423 + reopened_first = manager.open(first, context_id="ctx-a")
424 +
425 + ctx_a_files = manager.list_open("ctx-a")
426 + ctx_b_files = manager.list_open("ctx-b")
427 + prompt_context = open_files_context.build_context("ctx-a")
428 +
429 + assert reopened_first["session_id"] == first_session["session_id"]
430 + assert reopened_first["text"] == "Unsaved ctx-a text"
431 + assert [item["file_id"] for item in ctx_a_files] == [first["file_id"]]
432 + assert [item["file_id"] for item in ctx_b_files] == [second["file_id"]]
433 + assert ctx_a_files[0]["dirty"] is True
434 + assert ctx_a_files[0]["active"] is True
435 + assert ctx_a_files[0]["open_sessions"] == 1
436 + assert "First Editor Note.md" in prompt_context
437 + assert "Second Editor Note.md" not in prompt_context
438 + assert "First private body" not in prompt_context
439 + assert "Unsaved ctx-a text" not in prompt_context
440 +
441 +
442 def test_markdown_save_tracks_version_history(office_state):
443 doc = document_store.create_document("document", "Versioned", "md", "First")
444 updated = document_store.write_markdown(doc["file_id"], "# Versioned\n\nSecond\n")
@@ -462,8 +498,8 @@ def test_document_rename_saves_dirty_markdown_and_removes_original(office_state)
498
499
500 def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
465 - manager = markdown_sessions.MarkdownSessionManager()
466 - monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
501 + manager = editor_markdown_sessions.MarkdownSessionManager()
502 + monkeypatch.setattr(editor_markdown_sessions, "_manager", manager, raising=False)
503 doc = document_store.create_document("document", "Receiver", "md", "First")
504 session = manager.open(doc)
505
@@ -473,7 +509,7 @@ def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeyp
509
510
511 def test_markdown_session_rejects_office_binaries(office_state):
476 - manager = markdown_sessions.MarkdownSessionManager()
512 + manager = editor_markdown_sessions.MarkdownSessionManager()
513 doc = document_store.create_document("document", "Desktop Only", "odt", "Native text")
514
515 with pytest.raises(ValueError, match="Open .odt files in the Desktop"):
webui/js/surfaces.js
+7
@@ -24,6 +24,13 @@ export const CORE_SURFACES = [
24 order: 20,
25 modalPath: "/plugins/_desktop/webui/main.html",
26 },
27 + {
28 + id: "editor",
29 + title: "Editor",
30 + icon: "article",
31 + order: 30,
32 + modalPath: "/plugins/_editor/webui/main.html",
33 + },
34 ];
35
36 export function normalizeSurfaceId(surfaceId = "") {