diff --git a/plugins/_desktop/AGENTS.md b/plugins/_desktop/AGENTS.md index 2f2b300fd..e7bdef438 100644 --- a/plugins/_desktop/AGENTS.md +++ b/plugins/_desktop/AGENTS.md @@ -16,6 +16,7 @@ - Preserve session startup, cleanup, and route protection for desktop access. - Keep desktop state injected into prompts accurate and bounded. - Do not expose desktop routes without the expected auth protections. +- Keep Markdown and plain text file open-with handling routed to the Editor surface through the desktop intent bridge; Desktop owns the Xfce launcher/MIME setup, while Editor owns `.md` and `.txt` editing. ## Work Guidance diff --git a/plugins/_desktop/api/desktop_session.py b/plugins/_desktop/api/desktop_session.py index e0eead095..7b1120311 100644 --- a/plugins/_desktop/api/desktop_session.py +++ b/plugins/_desktop/api/desktop_session.py @@ -74,10 +74,10 @@ class DesktopSession(ApiHandler): return {"ok": False, "error": str(exc)} ext = str(doc.get("extension") or "").lower() - if ext == "md": + if ext in document_store.EDITOR_TEXT_EXTENSIONS: return { "ok": False, - "error": "Markdown documents use the Editor surface.", + "error": "Text documents use the Editor surface.", "requires_editor": True, "document": _public_doc(doc), } diff --git a/plugins/_desktop/helpers/desktop_session.py b/plugins/_desktop/helpers/desktop_session.py index 0545a5684..efacb2c41 100644 --- a/plugins/_desktop/helpers/desktop_session.py +++ b/plugins/_desktop/helpers/desktop_session.py @@ -23,7 +23,7 @@ from plugins._desktop.helpers import desktop_state from plugins._office.helpers import document_store, libreoffice -OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"} +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"} PLUGIN_NAME = "_desktop" SYSTEM_SESSION_ID = "agent-zero-desktop" SYSTEM_FILE_ID = "system-desktop" @@ -78,6 +78,7 @@ DESKTOP_FOLDER_LINKS = ( URL_INTENT_MAX_ITEMS = 50 URL_INTENT_MAX_LENGTH = 8192 URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop" +EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop" SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop" SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID SHUTDOWN_CONFIRM_SECONDS = 8 @@ -1135,6 +1136,7 @@ class DesktopSessionManager: applications_dir.mkdir(parents=True, exist_ok=True) browser_bridge = _write_url_bridge_script(session) + editor_bridge = _write_editor_bridge_script(session) shutdown_bridge = _write_shutdown_bridge_script(session) helpers_rc = config_dir / "xfce4" / "helpers.rc" helpers_rc.parent.mkdir(parents=True, exist_ok=True) @@ -1153,8 +1155,12 @@ class DesktopSessionManager: config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop", browser_bridge, ) - _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID) - _write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID) + _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID) + _write_mimeapps_defaults( + data_dir / "applications" / "mimeapps.list", + URL_HANDLER_DESKTOP_ID, + EDITOR_HANDLER_DESKTOP_ID, + ) _write_desktop_launcher( applications_dir / URL_HANDLER_DESKTOP_ID, name="Agent Zero Browser", @@ -1165,6 +1171,15 @@ class DesktopSessionManager: mime_types=_url_handler_mime_types(), no_display=True, ) + _write_desktop_launcher( + applications_dir / EDITOR_HANDLER_DESKTOP_ID, + name="Agent Zero Editor", + exec_line=_desktop_exec(editor_bridge, "%F"), + icon="accessories-text-editor", + categories="Utility;TextEditor;", + try_exec=str(editor_bridge), + mime_types=_editor_text_handler_mime_types(), + ) _write_desktop_launcher( applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID, name="Shutdown Desktop", @@ -1190,6 +1205,14 @@ class DesktopSessionManager: categories="Network;WebBrowser;", try_exec=str(browser_bridge), ) + _write_desktop_launcher( + desktop_dir / "Editor.desktop", + name="Editor", + exec_line=_desktop_exec(editor_bridge), + icon="accessories-text-editor", + categories="Utility;TextEditor;", + try_exec=str(editor_bridge), + ) self._trust_desktop_launchers(session, desktop_dir) def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None: @@ -1830,6 +1853,10 @@ def _url_bridge_script_path(session: DesktopSession) -> Path: return _url_bridge_dir(session) / "open-url" +def _editor_bridge_script_path(session: DesktopSession) -> Path: + return _url_bridge_dir(session) / "open-editor" + + def _url_bridge_queue_path(session: DesktopSession) -> Path: return _url_bridge_dir(session) / "browser-url-intents.jsonl" @@ -1900,6 +1927,64 @@ if __name__ == "__main__": return script +def _write_editor_bridge_script(session: DesktopSession) -> Path: + bridge_dir = _url_bridge_dir(session) + bridge_dir.mkdir(parents=True, exist_ok=True) + script = _editor_bridge_script_path(session) + queue = _url_bridge_queue_path(session) + lock = _url_bridge_lock_path(session) + script.write_text( + f"""#!/usr/bin/env python3 +import fcntl +import json +import os +import sys +import time +from urllib.parse import quote + +QUEUE_PATH = {str(queue)!r} +LOCK_PATH = {str(lock)!r} +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH} + + +def editor_url(path): + path = str(path or "").strip() + if not path: + return "a0-editor://open" + return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:") + + +def main(): + urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()] + if not urls: + urls = [editor_url("")] + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True) + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file: + for url in urls: + queue_file.write(json.dumps({{ + "url": url, + "created_at": time.time(), + "source": "desktop-editor", + }}, ensure_ascii=True) + "\\n") + queue_file.flush() + os.fsync(queue_file.fileno()) + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +if __name__ == "__main__": + main() +""", + encoding="utf-8", + ) + try: + script.chmod(0o755) + except OSError: + pass + return script + + def _write_shutdown_bridge_script(session: DesktopSession) -> Path: bridge_dir = _url_bridge_dir(session) bridge_dir.mkdir(parents=True, exist_ok=True) @@ -2102,14 +2187,25 @@ def _url_handler_mime_types() -> tuple[str, ...]: ) -def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None: - associations = ";".join([desktop_id, ""]) +def _editor_text_handler_mime_types() -> tuple[str, ...]: + return ( + "text/markdown", + "text/x-markdown", + "text/plain", + ) + + +def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None: + url_associations = ";".join([url_desktop_id, ""]) + editor_associations = ";".join([editor_desktop_id, ""]) lines = [ "[Default Applications]", - *(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={editor_desktop_id}" for mime_type in _editor_text_handler_mime_types()), "", "[Added Associations]", - *(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()), + *(f"{mime_type}={editor_associations}" for mime_type in _editor_text_handler_mime_types()), "", ] path.parent.mkdir(parents=True, exist_ok=True) diff --git a/plugins/_desktop/webui/desktop-store.js b/plugins/_desktop/webui/desktop-store.js index 1408ecad6..9c793da90 100644 --- a/plugins/_desktop/webui/desktop-store.js +++ b/plugins/_desktop/webui/desktop-store.js @@ -2239,7 +2239,12 @@ const model = { async openDesktopUrlIntent(intent = {}) { const url = String(intent?.url || "").trim(); const handled = await handleUrlIntent({ url, source: "desktop-url" }); - this.setMessage(handled ? "Opened link in Browser" : "Browser is not available"); + const isEditorIntent = url.startsWith("a0-editor:"); + this.setMessage( + handled + ? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser") + : (isEditorIntent ? "Editor is not available" : "Browser is not available"), + ); }, browserDestinationForDesktopUrl() { diff --git a/plugins/_editor/AGENTS.md b/plugins/_editor/AGENTS.md index 19d823537..f7c2f25b9 100644 --- a/plugins/_editor/AGENTS.md +++ b/plugins/_editor/AGENTS.md @@ -2,12 +2,12 @@ ## Purpose -- Own the native Markdown editor surface for canvas and floating modal workflows. +- Own the native Markdown and plain text editor surface for canvas and floating modal workflows. ## Ownership - `api/` owns editor session and WebSocket handlers. -- `helpers/` owns markdown session and open-file context helpers. +- `helpers/` owns editor text session and open-file context helpers. - `prompts/` owns agent-visible open-file context. - `webui/` owns editor panel, preview, store, and main surface. - `extensions/` owns editor hook contributions. @@ -17,6 +17,8 @@ - Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior. - Do not expose unsaved content or local paths beyond intended chat/context surfaces. - Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work. +- Keep Editor Open wired through the File Browser text picker so users can open one or more Markdown or plain text files with an obvious confirmation action. +- Keep Save As distinct from Rename: Save As writes the current editor text to a chosen `.md` or `.txt` path and retargets the active session without removing the original file. ## Work Guidance diff --git a/plugins/_editor/api/editor_session.py b/plugins/_editor/api/editor_session.py index 5ef197648..60dbf4578 100644 --- a/plugins/_editor/api/editor_session.py +++ b/plugins/_editor/api/editor_session.py @@ -38,13 +38,13 @@ class EditorSession(ApiHandler): return closed if action == "create": fmt = str(input.get("format") or "md").lower().lstrip(".") - if fmt != "md": - return {"ok": False, "error": "Editor can only create Markdown documents."} + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS: + return {"ok": False, "error": "Editor can only create Markdown (.md) and text (.txt) documents."} try: doc = document_store.create_document( kind="document", title=str(input.get("title") or "Untitled"), - fmt="md", + fmt=fmt, content=str(input.get("content") or ""), path=str(input.get("path") or ""), context_id=context_id, @@ -72,6 +72,28 @@ class EditorSession(ApiHandler): if not session_id: return {"ok": False, "error": "session_id is required."} return markdown_sessions.get_manager().save(session_id, text=input.get("text")) + if action == "save_as": + session_id = str(input.get("session_id") or "").strip() + path = str(input.get("path") or "").strip() + if not session_id: + return {"ok": False, "error": "session_id is required."} + if not path: + return {"ok": False, "error": "path is required."} + try: + result = markdown_sessions.get_manager().save_as(session_id, path, text=input.get("text")) + except Exception as exc: + return {"ok": False, "error": str(exc)} + document_store.close_session(session_id=str(input.get("store_session_id") or "").strip()) + store_session = document_store.create_session( + result["document"]["file_id"], + user_id=str(input.get("user_id") or "agent-zero-user"), + permission="write", + origin=self._origin(request), + ) + return { + **result, + "store_session_id": store_session["session_id"], + } if action == "renamed": return self._renamed(input, context_id) if action == "refresh": @@ -85,7 +107,7 @@ class EditorSession(ApiHandler): request: Request, context_id: str = "", ) -> dict: - if str(doc.get("extension") or "").lower() != "md": + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS: return { "ok": False, "error": f".{doc.get('extension', '')} documents use the Desktop surface.", diff --git a/plugins/_editor/api/ws_editor.py b/plugins/_editor/api/ws_editor.py index 7c8e6e446..8adf31d1c 100644 --- a/plugins/_editor/api/ws_editor.py +++ b/plugins/_editor/api/ws_editor.py @@ -53,10 +53,17 @@ class WsEditor(WsHandler): elif path: doc = document_store.register_document(path, context_id=context_id) else: + fmt = str(data.get("format") or "md").lower().strip().lstrip(".") + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS: + return WsResult.error( + code="UNSUPPORTED_EDITOR_DOCUMENT", + message=f"Editor can only create Markdown (.md) and text (.txt) documents, not .{fmt}.", + correlation_id=data.get("correlationId"), + ) doc = document_store.create_document( kind="document", title=str(data.get("title") or "Untitled"), - fmt="md", + fmt=fmt, content=str(data.get("content") or ""), context_id=context_id, ) diff --git a/plugins/_editor/helpers/markdown_sessions.py b/plugins/_editor/helpers/markdown_sessions.py index 64eb39f43..0347fa3cb 100644 --- a/plugins/_editor/helpers/markdown_sessions.py +++ b/plugins/_editor/helpers/markdown_sessions.py @@ -31,7 +31,7 @@ class MarkdownSession: class MarkdownSessionManager: - """Owns native Editor sessions for Markdown documents.""" + """Owns native Editor sessions for Markdown and plain text documents.""" def __init__(self) -> None: self._sessions: dict[str, MarkdownSession] = {} @@ -39,8 +39,8 @@ class MarkdownSessionManager: def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "", refresh: bool = False) -> dict[str, Any]: ext = str(doc["extension"]).lower() - if ext != "md": - raise ValueError(f"Editor is only available for Markdown. Open .{ext} files in the Desktop.") + if ext not in document_store.EDITOR_TEXT_EXTENSIONS: + raise ValueError(f"Editor is only available for Markdown and text files. Open .{ext} files in the Desktop.") normalized_context = str(context_id or "") if refresh: @@ -64,6 +64,7 @@ class MarkdownSessionManager: _mark_session_external(session, doc) session.path = doc["path"] session.title = doc["basename"] + session.extension = ext session.updated_at = time.time() self.activate(session.session_id) return self._payload(session, doc) @@ -103,11 +104,12 @@ class MarkdownSessionManager: if conflict is not None: return conflict - updated = document_store.write_markdown(session.file_id, session.text) + updated = document_store.write_text_document(session.file_id, session.text) session.updated_at = time.time() session.dirty = False session.path = updated["path"] session.title = updated["basename"] + session.extension = str(updated.get("extension") or session.extension).lower() _set_session_base(session, updated) self._refresh_file_sessions( updated, @@ -121,6 +123,32 @@ class MarkdownSessionManager: "version": document_store.item_version(updated), } + def save_as(self, session_id: str, path: str, text: str | None = None) -> dict[str, Any]: + session = self._require(session_id) + if text is not None: + session.text = str(text) + + updated = document_store.save_text_document_as( + session.file_id, + path, + session.text, + context_id=session.context_id, + ) + old_file_id = session.file_id + session.file_id = updated["file_id"] + session.updated_at = time.time() + session.dirty = False + session.path = updated["path"] + session.title = updated["basename"] + session.extension = str(updated.get("extension") or session.extension).lower() + _set_session_base(session, updated) + return { + "ok": True, + "previous_file_id": old_file_id, + "document": _public_doc(updated), + "version": document_store.item_version(updated), + } + def activate(self, session_id: str) -> dict[str, Any]: session = self._require(session_id) now = time.time() @@ -145,7 +173,7 @@ class MarkdownSessionManager: doc = document_store.get_document(normalized) except Exception: return {"ok": False, "refreshed": 0, "sessions": []} - if str(doc.get("extension") or "").lower() != "md": + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS: return {"ok": True, "refreshed": 0, "sessions": []} refreshed = self._refresh_file_sessions( @@ -342,6 +370,7 @@ class MarkdownSessionManager: session.text = str(text) session.path = doc["path"] session.title = doc["basename"] + session.extension = str(doc.get("extension") or session.extension).lower() if dirty is not None and (can_replace_text or refresh_dirty or not session.dirty): session.dirty = dirty if can_replace_text or not session.dirty: diff --git a/plugins/_editor/helpers/open_files_context.py b/plugins/_editor/helpers/open_files_context.py index eaa6e1481..54d45e1c3 100644 --- a/plugins/_editor/helpers/open_files_context.py +++ b/plugins/_editor/helpers/open_files_context.py @@ -11,7 +11,7 @@ def build_context(context_id: str = "", max_items: int = 20) -> str: return "" lines = [ - "These Markdown files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.", + "These Markdown or plain text files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.", ] for item in files: lines.append(format_open_file_line(item)) diff --git a/plugins/_editor/webui/editor-panel.html b/plugins/_editor/webui/editor-panel.html index 310a8f7a3..309c5cb27 100644 --- a/plugins/_editor/webui/editor-panel.html +++ b/plugins/_editor/webui/editor-panel.html @@ -9,7 +9,7 @@