From ac69638a3a1c8707dea0cd6e36c83d47ec78e18a Mon Sep 17 00:00:00 2001 From: shartsock <33919+hartsock@users.noreply.github.com> Date: Fri, 29 May 2026 14:19:00 -0400 Subject: [PATCH 1/2] feat(app): tab path bar, theme sync, view cycle, vim toggle, Word export + MCP parity Five editor UX features for scrybe-app, each with a matching MCP tool per the project's human/MCP control-parity rule: - Path bar: selectable full-path display + copy button above the editor (MCP: `state` reports the active path/title/dirty). - Theme sync: the CodeMirror editor now matches the preview theme (light/dark/solarized) via a theme compartment (MCP: `set_theme`). - View cycle: the toolbar View button cycles both -> edit -> preview, matching the per-tab mode icon (MCP: `view_mode`). - Vim: toggleable Vim keybindings via a compartment (MCP: `set_vim`). - Word export: toolbar Export button + `export_docx` command shells to `scrybe-docx`; Mermaid blocks render to PNGs with the source embedded in PNG metadata, round-trippable via `scrybe_mermaid.extract` (MCP: `export`). scrybe-app mirrors its UI state to /tmp/scrybe-state.json (publish_state) and polls signal files (poll_set_theme/poll_view_mode/poll_set_vim) so the MCP tools drive the exact controls a human clicks. Also fixes scrybe-plugin-docx: invalid build-backend (package would not install, so scrybe-docx was unavailable), embeds Mermaid source into the rendered diagram PNGs, and corrects a monkeypatch in the mermaid-fallback test that only passed when mmdc was absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + AGENTS.md | 11 + CLAUDE.md | 9 +- README.md | 2 +- scrybe-app/index.html | 1 + scrybe-app/package-lock.json | 27 +++ scrybe-app/package.json | 2 + scrybe-app/src-tauri/src/lib.rs | 94 +++++++++ scrybe-app/src/editor.ts | 51 +++++ scrybe-app/src/main.ts | 143 ++++++++++++- scrybe-app/src/pathbar.ts | 37 ++++ scrybe-app/src/styles/pathbar.css | 49 +++++ scrybe-app/src/toolbar.ts | 46 ++++- scrybe-mcp-server/src/tools.rs | 191 +++++++++++++++++- scrybe-plugin-docx/pyproject.toml | 7 +- .../scrybe_plugin_docx/renderer.py | 16 ++ scrybe-plugin-docx/tests/test_renderer.py | 15 +- 17 files changed, 678 insertions(+), 26 deletions(-) create mode 100644 scrybe-app/src/pathbar.ts create mode 100644 scrybe-app/src/styles/pathbar.css diff --git a/.gitignore b/.gitignore index 7b32d4e..0ce2e86 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ __pycache__/ # autosaves. Cleared on explicit save (Ctrl+S) or tab close; should # never be committed. *.scrybe-buffer + +# Python editable-install artifacts +*.egg-info/ diff --git a/AGENTS.md b/AGENTS.md index ad5f9cb..582354b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,19 @@ scrybe-mcp-server tools | `extract` | Extract Mermaid source from a PNG | `png_path` | | `lint` | Word count, headings, code blocks, links | `id` | | `logs` | Read recent console log entries from the GUI | `tail?` | +| `reload` | Re-read an open document from disk into the GUI | `id`, `force?` | | `close_tab` | Close a tab by path (omit path = close active tab) | `path?` | | `quit` | Gracefully close the Scrybe GUI window | — | +| `state` | Report the GUI's active path, view mode, theme, and Vim state | — | +| `set_theme` | Set editor + preview theme (human: theme dropdown) | `theme` | +| `view_mode` | Set active tab view mode (human: View button) | `mode` | +| `set_vim` | Toggle Vim keybindings (human: Vim toggle) | `enabled` | +| `export` | Export Markdown to Word (.docx) with Mermaid PNGs | `input`, `output?`, `no_diagrams?` | + +> **Parity rule:** every human control in scrybe-app has an MCP equivalent +> and vice versa. The `state`/`set_theme`/`view_mode`/`set_vim`/`export` +> tools mirror the path bar, theme dropdown, View button, Vim toggle, and +> Export button respectively. ### Document IDs diff --git a/CLAUDE.md b/CLAUDE.md index bb1fe05..4b9ddcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ not make the toolchain more impressive. |---|---|---| | `scrybe-core/` | Rust | AST, Document, ContentAddressable (BLAKE3+CBOR), Plugin trait, Workspace | | `scrybe-render/` | Rust | HTML pipeline, syntect highlighting, KaTeX/Mermaid | -| `scrybe-mcp-server/` | Rust | Inbound MCP server — tools: open/read/section/edit/find/render/embed/extract/lint/logs/quit/close_tab | +| `scrybe-mcp-server/` | Rust | Inbound MCP server — tools: open/read/section/edit/find/render/embed/extract/lint/logs/reload/quit/close_tab + UI-parity tools state/set_theme/view_mode/set_vim/export | | `scrybe-mcp-client/` | Rust | Outbound MCP — registers external agent servers | | `scrybe-mermaid/` | Rust | PNG iTXt codec (Mermaid source embedded in PNG metadata) | | `scrybe-panels/` | Rust | Bake-off orchestrator + SQLite calibration log | @@ -159,7 +159,12 @@ claude mcp add scrybe -- scrybe-mcp-server stdio ``` Available tools: `open`, `read`, `section`, `edit`, `find`, `render`, -`embed`, `extract`, `lint`, `logs`, `quit`, `close_tab` +`embed`, `extract`, `lint`, `logs`, `reload`, `quit`, `close_tab`, +`state`, `set_theme`, `view_mode`, `set_vim`, `export` + +Every human control in scrybe-app has an MCP equivalent and vice versa +(`state`/`set_theme`/`view_mode`/`set_vim`/`export` mirror the path bar, +theme dropdown, View button, Vim toggle, and Export button). See `AGENTS.md` for full agent interaction guide. diff --git a/README.md b/README.md index 1a3bba6..747f0f6 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ scrybe # open the welcome screen claude mcp add scrybe -- scrybe-mcp-server stdio ``` -MCP tools: `open` · `read` · `section` · `edit` · `find` · `render` · `embed` · `extract` · `lint` · `logs` · `close_tab` · `quit` +MCP tools: `open` · `read` · `section` · `edit` · `find` · `render` · `embed` · `extract` · `lint` · `logs` · `reload` · `close_tab` · `quit` · `state` · `set_theme` · `view_mode` · `set_vim` · `export` ## Development diff --git a/scrybe-app/index.html b/scrybe-app/index.html index ee9d9a3..f5b8685 100644 --- a/scrybe-app/index.html +++ b/scrybe-app/index.html @@ -73,6 +73,7 @@
+
diff --git a/scrybe-app/package-lock.json b/scrybe-app/package-lock.json index ce1be22..f4ca6b2 100644 --- a/scrybe-app/package-lock.json +++ b/scrybe-app/package-lock.json @@ -14,7 +14,9 @@ "@codemirror/language": "^6.10", "@codemirror/language-data": "^6.5", "@codemirror/state": "^6.5", + "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.36", + "@replit/codemirror-vim": "^6.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2", @@ -418,6 +420,18 @@ "@marijn/find-cluster-break": "^1.0.0" } }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@codemirror/view": { "version": "6.42.0", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.0.tgz", @@ -1055,6 +1069,19 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@replit/codemirror-vim": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", + "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==", + "license": "MIT", + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", diff --git a/scrybe-app/package.json b/scrybe-app/package.json index c165e9b..85c96f2 100644 --- a/scrybe-app/package.json +++ b/scrybe-app/package.json @@ -16,7 +16,9 @@ "@codemirror/language": "^6.10", "@codemirror/language-data": "^6.5", "@codemirror/state": "^6.5", + "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.36", + "@replit/codemirror-vim": "^6.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2", diff --git a/scrybe-app/src-tauri/src/lib.rs b/scrybe-app/src-tauri/src/lib.rs index 71d68f9..59de6eb 100644 --- a/scrybe-app/src-tauri/src/lib.rs +++ b/scrybe-app/src-tauri/src/lib.rs @@ -750,6 +750,95 @@ fn note_autosave(path: String) { } } +// ─── UI-control parity: state mirror + MCP signal pollers ──────────────────── +// +// Project rule: every human control has an MCP equivalent and vice versa. +// The frontend mirrors its UI state here so the MCP `state` tool can read +// it, and polls the signal files below so the MCP `set_theme`/`view_mode`/ +// `set_vim` tools can drive the same controls a human clicks. + +/// Mirror the frontend's current UI state to `/tmp/scrybe-state.json`. +/// Read by the MCP `state` tool. Best-effort; never blocks the UI. +#[tauri::command] +fn publish_state(state: serde_json::Value) -> Result<(), String> { + let s = serde_json::to_string(&state).map_err(|e| e.to_string())?; + std::fs::write("/tmp/scrybe-state.json", s).map_err(|e| e.to_string()) +} + +/// Read-and-delete a single-shot signal file written by an MCP tool. +/// Returns the trimmed contents, or None if no signal is pending. +fn poll_signal(path: &str) -> Option { + let p = std::path::Path::new(path); + if !p.exists() { + return None; + } + let content = std::fs::read_to_string(p).ok()?; + let _ = std::fs::remove_file(p); + Some(content.trim().to_string()) +} + +/// Poll for an MCP `set_theme` signal (`default` | `dark` | `solarized`). +#[tauri::command] +fn poll_set_theme() -> Option { + poll_signal("/tmp/scrybe-set-theme.txt") +} + +/// Poll for an MCP `view_mode` signal (`both` | `edit` | `preview` | `cycle`). +#[tauri::command] +fn poll_view_mode() -> Option { + poll_signal("/tmp/scrybe-view-mode.txt") +} + +/// Poll for an MCP `set_vim` signal (`on` | `off`). +#[tauri::command] +fn poll_set_vim() -> Option { + poll_signal("/tmp/scrybe-set-vim.txt") +} + +/// Locate the `scrybe-docx` CLI (from the `scrybe-plugin-docx` package). +fn which_scrybe_docx() -> Result { + which::which("scrybe-docx") + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| { + "scrybe-docx not found on PATH. Install with: pip install scrybe-plugin-docx" + .to_string() + }) +} + +/// Export Markdown `content` to a Word (.docx) file at `output` by shelling +/// to `scrybe-docx`. The buffer is piped on stdin so unsaved edits export +/// too; `scrybe-docx` renders Mermaid fenced blocks to PNGs with the source +/// embedded in PNG metadata. Mirrored by the MCP `export` tool. +#[tauri::command] +fn export_docx(content: String, output: String, no_diagrams: bool) -> Result { + use std::io::Write as _; + let bin = which_scrybe_docx()?; + let mut cmd = Command::new(&bin); + cmd.arg("-o").arg(&output); + if no_diagrams { + cmd.arg("--no-diagrams"); + } + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn scrybe-docx: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(content.as_bytes()) + .map_err(|e| format!("write to scrybe-docx stdin failed: {e}"))?; + } + let out = child + .wait_with_output() + .map_err(|e| format!("scrybe-docx failed: {e}"))?; + if out.status.success() { + Ok(output) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -772,6 +861,11 @@ pub fn run() { list_directory, poll_close_tab, poll_reload_tab, + publish_state, + poll_set_theme, + poll_view_mode, + poll_set_vim, + export_docx, watch_file, unwatch_file, note_autosave, diff --git a/scrybe-app/src/editor.ts b/scrybe-app/src/editor.ts index 6fdc140..3f4ca7f 100644 --- a/scrybe-app/src/editor.ts +++ b/scrybe-app/src/editor.ts @@ -4,9 +4,48 @@ import { EditorView } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { vim } from "@replit/codemirror-vim"; +/// Holds the active editor color theme. Reconfigured by `setEditorTheme` +/// so the editor chrome matches the preview pane's theme selection. export const themeCompartment = new Compartment(); +/// Holds the optional Vim keymap. Reconfigured by `setVim` so the user +/// can toggle modal editing on and off without rebuilding the view. +export const vimCompartment = new Compartment(); + +/// A light, warm CodeMirror theme tuned to match the preview pane's +/// "solarized" palette (`preview.css`: bg #fdf6e3, fg #657b83). The +/// preview's syntect/markdown styling stays light, so we only need to +/// recolor the editor chrome (background, cursor, selection, gutter). +const solarizedTheme = EditorView.theme( + { + "&": { backgroundColor: "#fdf6e3", color: "#586e75" }, + ".cm-content": { caretColor: "#586e75" }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#586e75" }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: "#eee8d5" }, + ".cm-gutters": { backgroundColor: "#eee8d5", color: "#93a1a1", border: "none" }, + ".cm-activeLine": { backgroundColor: "#eee8d5aa" }, + ".cm-activeLineGutter": { backgroundColor: "#eee8d5" }, + }, + { dark: false }, +); + +/// Map a theme name (shared with the preview pane) to a CodeMirror theme +/// extension. "default" is CodeMirror's built-in light theme (no extension). +function editorThemeExtension(theme: string) { + switch (theme) { + case "dark": + return oneDark; + case "solarized": + return solarizedTheme; + default: + return []; + } +} + export function createEditor( parent: HTMLElement, initialDoc: string, @@ -16,6 +55,8 @@ export function createEditor( state: EditorState.create({ doc: initialDoc, extensions: [ + // Vim must precede basicSetup so its keymap wins when enabled. + vimCompartment.of([]), basicSetup, markdown({ base: markdownLanguage, codeLanguages: languages }), themeCompartment.of([]), @@ -28,6 +69,16 @@ export function createEditor( }); } +/// Reconfigure the editor's color theme to match the preview pane. +export function setEditorTheme(view: EditorView, theme: string): void { + view.dispatch({ effects: themeCompartment.reconfigure(editorThemeExtension(theme)) }); +} + +/// Enable or disable the Vim keymap in the running editor. +export function setVim(view: EditorView, enabled: boolean): void { + view.dispatch({ effects: vimCompartment.reconfigure(enabled ? vim() : []) }); +} + /// Set true while a programmatic dispatch is replacing buffer content /// (e.g., tab switch, file load, external-change reload). CodeMirror's /// updateListener fires synchronously during dispatch; consumers can diff --git a/scrybe-app/src/main.ts b/scrybe-app/src/main.ts index 78c2fb0..fe659ee 100644 --- a/scrybe-app/src/main.ts +++ b/scrybe-app/src/main.ts @@ -5,16 +5,20 @@ import "./styles/preview.css"; import "./styles/sidebar.css"; import "./styles/mcp_panel.css"; import "./styles/vcs_panel.css"; +import "./styles/pathbar.css"; import { invoke, convertFileSrc } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { homeDir } from "@tauri-apps/api/path"; import { open as openExternal } from "@tauri-apps/plugin-shell"; +import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { showToast } from "./toast"; import { AppState } from "./state"; import { renderTabBar } from "./tabs"; -import { createEditor, swapDocument, shouldSuppressAutosave } from "./editor"; +import { createEditor, swapDocument, shouldSuppressAutosave, setEditorTheme, setVim } from "./editor"; import { PreviewPane } from "./preview"; -import { buildToolbar } from "./toolbar"; +import { buildToolbar, setToolbarViewMode, setToolbarTheme, setToolbarVim } from "./toolbar"; +import type { Theme } from "./toolbar"; +import { renderPathBar } from "./pathbar"; import { Sidebar } from "./sidebar"; import { PluginManager } from "./plugins"; import { McpPanel } from "./mcp_panel"; @@ -72,6 +76,7 @@ pluginManager.load(); const state = new AppState(); const tabBarEl = document.getElementById("tab-bar")!; +const pathBarEl = document.getElementById("path-bar")!; const editorEl = document.getElementById("editor")!; const previewEl = document.getElementById("preview")!; const toolbarEl = document.getElementById("toolbar")!; @@ -79,6 +84,11 @@ const sidebarEl = document.getElementById("sidebar")!; const preview = new PreviewPane(previewEl); +// Current editor/preview theme and Vim state. These are app-wide (not +// per-tab) and are mirrored to the MCP `state` tool via `publishState`. +let currentTheme: Theme = "default"; +let vimEnabled = false; + function flashSidebar(dir: string): void { sidebar.loadDirectory(dir); sidebarEl.classList.add("sidebar-folder-flash"); @@ -191,20 +201,108 @@ async function reloadActiveTabNow(): Promise { } buildToolbar(toolbarEl, { - onThemeChange: (theme) => { - preview.setTheme(theme); - const tab = state.activeTab(); - if (tab) preview.render(tab.content); - }, - onTogglePreview: () => { - previewEl.style.display = previewEl.style.display === "none" ? "" : "none"; - }, + onThemeChange: (theme) => applyTheme(theme), + onCyclePreview: () => cyclePreviewMode(), onOpenFile: openFileByPath, onOpenFolder: (path) => sidebar.loadDirectory(path), onSave: () => { void saveActiveTabNow(); }, onReload: () => { void reloadActiveTabNow(); }, + onExport: () => { void exportActiveTabToWord(); }, + onToggleVim: () => setVimEnabled(!vimEnabled), }); +/// Apply a theme to BOTH panes so the editor chrome matches the preview, +/// update the toolbar dropdown, and mirror the choice to the MCP `state` +/// tool. This is the single entry point for theme changes — the toolbar +/// dropdown and the MCP `set_theme` poller both route through here. +function applyTheme(theme: Theme): void { + currentTheme = theme; + preview.setTheme(theme); + setEditorTheme(view, theme); + setToolbarTheme(toolbarEl, theme); + const tab = state.activeTab(); + if (tab) preview.render(tab.content); + publishState(); +} + +/// Cycle the active tab's view mode (both → edit → preview → both). Shared +/// by the toolbar View button and the MCP `view_mode` poller. +function cyclePreviewMode(): void { + const id = state.activeTabId; + if (!id) return; + applyViewMode(state.cycleViewMode(id)); + redrawTabs(); +} + +/// Set a specific view mode on the active tab (used by the MCP poller when +/// a concrete mode, not "cycle", is requested). +function setViewMode(mode: string): void { + const tab = state.activeTab(); + if (!tab) return; + if (mode === "both" || mode === "edit" || mode === "preview") { + tab.viewMode = mode; + applyViewMode(mode); + redrawTabs(); + } +} + +/// Enable/disable Vim in the editor, update the toolbar button, mirror to +/// MCP. Shared by the toolbar toggle and the MCP `set_vim` poller. +function setVimEnabled(on: boolean): void { + vimEnabled = on; + setVim(view, on); + setToolbarVim(toolbarEl, on); + publishState(); +} + +/// Publish the current UI state to `/tmp/scrybe-state.json` so the MCP +/// `state` tool can report what the human is looking at (active path, +/// view mode, theme, vim). The human-side equivalents are the path bar, +/// the tab mode icon, the theme dropdown, and the Vim toggle. +function publishState(): void { + const tab = state.activeTab(); + invoke("publish_state", { + state: { + active_path: tab?.path ?? null, + active_title: tab?.title ?? null, + is_dirty: tab?.isDirty ?? false, + view_mode: tab?.viewMode ?? "both", + theme: currentTheme, + vim: vimEnabled, + open_paths: state.tabs.map(t => t.path).filter((p): p is string => !!p), + }, + }).catch(() => { /* state mirror is best-effort */ }); +} + +/// Update the selectable path bar to show the active tab's full path. +function updatePathBar(): void { + renderPathBar(pathBarEl, state.activeTab()); +} + +/// Export the active tab's current buffer to a Word (.docx) file. Prompts +/// for a destination, then shells to `scrybe-docx` via the backend. The +/// MCP `export` tool is the agent-side equivalent. +async function exportActiveTabToWord(): Promise { + const tab = state.activeTab(); + if (!tab) { showToast("No tab to export", "info"); return; } + const base = tab.path + ? tab.path.replace(/\.[^/.]+$/, "").split("/").pop() ?? "document" + : "document"; + const home = await homeDir(); + const dest = await saveDialog({ + defaultPath: `${home}/${base}.docx`, + filters: [{ name: "Word document", extensions: ["docx"] }], + }); + if (!dest) return; + try { + await invoke("export_docx", { content: tab.content, output: dest, noDiagrams: false }); + showToast(`Exported ${dest.split("/").pop()}`, "info"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showToast(`Export failed: ${msg}`); + } +} + // Keyboard shortcuts for save + reload (mirrors the toolbar buttons). window.addEventListener("keydown", (e) => { const mod = e.metaKey || e.ctrlKey; @@ -284,6 +382,8 @@ const editorAndPreview = document.getElementById("editor-and-preview")!; function applyViewMode(mode: string): void { editorAndPreview.classList.remove("mode-both", "mode-edit", "mode-preview"); editorAndPreview.classList.add(`mode-${mode}`); + setToolbarViewMode(toolbarEl, mode); + publishState(); } function redrawTabs(): void { @@ -293,6 +393,7 @@ function redrawTabs(): void { () => newTab(), id => { applyViewMode(state.cycleViewMode(id)); redrawTabs(); }, ); + updatePathBar(); } function selectTab(id: string): void { @@ -508,6 +609,10 @@ document.addEventListener("keydown", e => { // ─── P4.11 — terminal panel disabled (re-enable when MCP session control is wired) ─── newTab(); +// Sync the toolbar widgets + MCP state mirror with the initial defaults. +applyViewMode(state.activeTab()?.viewMode ?? "both"); +setToolbarTheme(toolbarEl, currentTheme); +setToolbarVim(toolbarEl, vimEnabled); invoke("get_initial_directory").then(dir => dir ? sidebar.loadDirectory(dir) : homeDir().then(home => sidebar.loadDirectory(home)) ).catch(console.error); @@ -536,6 +641,24 @@ setInterval(async () => { if (path !== null) closeTabByPath(path); }, 500); +// ─── MCP control pollers (human ↔ MCP parity) ──────────────────────────────── +// +// The MCP server writes a signal file for each UI control it drives; the +// frontend polls and applies it through the same code path as the human +// toolbar control. See `scrybe-mcp-server/src/tools.rs`. +setInterval(async () => { + const theme = await invoke("poll_set_theme").catch(() => null); + if (theme && (theme === "default" || theme === "dark" || theme === "solarized")) { + applyTheme(theme); + } + const mode = await invoke("poll_view_mode").catch(() => null); + if (mode === "cycle") cyclePreviewMode(); + else if (mode) setViewMode(mode); + const vim = await invoke("poll_set_vim").catch(() => null); + if (vim === "on") setVimEnabled(true); + else if (vim === "off") setVimEnabled(false); +}, 500); + // ─── Reload: MCP-driven (poll) + OS file watcher (event) ───────────────────── async function reloadTabFromDisk(path: string): Promise { diff --git a/scrybe-app/src/pathbar.ts b/scrybe-app/src/pathbar.ts new file mode 100644 index 0000000..0e8af1a --- /dev/null +++ b/scrybe-app/src/pathbar.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +import { showToast } from "./toast"; +import type { TabEntry } from "./state"; + +/// Renders the path bar above the editor: the active tab's full path as +/// selectable text, plus a copy button. Lets the user select/copy the +/// filename of whatever they're looking at — the human-side equivalent of +/// the MCP `state` tool, which reports the same active path to an agent. +export function renderPathBar(container: HTMLElement, tab: TabEntry | undefined): void { + container.innerHTML = ""; + + const text = document.createElement("span"); + text.className = "pb-path"; + const path = tab?.path ?? null; + text.textContent = path ?? "(unsaved buffer)"; + text.title = path ?? "This tab has no file on disk yet"; + if (!path) text.classList.add("pb-empty"); + container.appendChild(text); + + const copy = document.createElement("button"); + copy.className = "pb-copy"; + copy.textContent = "📋"; + copy.title = "Copy full path"; + copy.disabled = !path; + copy.onclick = () => { + if (!path) return; + navigator.clipboard.writeText(path).then( + () => { + copy.textContent = "✓"; + showToast(`Copied path: ${path.split("/").pop()}`, "info"); + setTimeout(() => { copy.textContent = "📋"; }, 1200); + }, + () => showToast("Copy failed"), + ); + }; + container.appendChild(copy); +} diff --git a/scrybe-app/src/styles/pathbar.css b/scrybe-app/src/styles/pathbar.css new file mode 100644 index 0000000..82fd900 --- /dev/null +++ b/scrybe-app/src/styles/pathbar.css @@ -0,0 +1,49 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Path bar — selectable full-path display above the editor (#1). */ + +#path-bar { + display: flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + background: #252526; + border-bottom: 1px solid #333; + flex-shrink: 0; + font-size: 11px; + color: #b0b0b0; +} + +#path-bar .pb-path { + flex: 1; + min-width: 0; + overflow-x: auto; + white-space: nowrap; + /* The whole point of this bar: let the user select the path text. */ + user-select: text; + cursor: text; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + scrollbar-width: none; +} + +#path-bar .pb-path::-webkit-scrollbar { height: 0; } + +#path-bar .pb-path.pb-empty { + color: #6b6b6b; + font-style: italic; +} + +#path-bar .pb-copy { + flex-shrink: 0; + background: transparent; + border: 1px solid #444; + border-radius: 3px; + color: #ccc; + font-size: 11px; + line-height: 1; + padding: 2px 6px; + cursor: pointer; +} + +#path-bar .pb-copy:hover:not(:disabled) { background: #3a3a3a; } +#path-bar .pb-copy:disabled { opacity: 0.4; cursor: default; } diff --git a/scrybe-app/src/toolbar.ts b/scrybe-app/src/toolbar.ts index 5e57cea..d3780c1 100644 --- a/scrybe-app/src/toolbar.ts +++ b/scrybe-app/src/toolbar.ts @@ -7,7 +7,9 @@ export type Theme = "default" | "dark" | "solarized"; export interface ToolbarHandlers { onThemeChange: (theme: Theme) => void; - onTogglePreview: () => void; + /// Cycle the active tab's view mode (both → edit → preview → both), + /// mirroring the per-tab mode icon in the tab bar. + onCyclePreview: () => void; onOpenFile: (path: string) => void; onOpenFolder: (path: string) => void; /// Save the active tab to disk. No-op when there is no active tab or @@ -16,6 +18,10 @@ export interface ToolbarHandlers { /// Reload the active tab from disk. If the buffer is dirty, the /// existing conflict-bar flow takes over (Keep mine / Take theirs). onReload: () => void; + /// Export the active tab to a Word (.docx) document. + onExport: () => void; + /// Toggle the Vim keymap in the editor on/off. + onToggleVim: () => void; } export function buildToolbar(container: HTMLElement, handlers: ToolbarHandlers): void { @@ -25,14 +31,16 @@ export function buildToolbar(container: HTMLElement, handlers: ToolbarHandlers):
+ - - + +
`; @@ -40,6 +48,8 @@ export function buildToolbar(container: HTMLElement, handlers: ToolbarHandlers): .addEventListener("click", () => handlers.onSave()); container.querySelector("#tb-reload")! .addEventListener("click", () => handlers.onReload()); + container.querySelector("#tb-export")! + .addEventListener("click", () => handlers.onExport()); container.querySelector("#open-file")! .addEventListener("click", async () => { const home = await homeDir(); @@ -63,8 +73,36 @@ export function buildToolbar(container: HTMLElement, handlers: ToolbarHandlers): }); container.querySelector("#theme-select")! .addEventListener("change", e => handlers.onThemeChange((e.target as HTMLSelectElement).value as Theme)); + container.querySelector("#toggle-vim")! + .addEventListener("click", () => handlers.onToggleVim()); container.querySelector("#toggle-preview")! - .addEventListener("click", handlers.onTogglePreview); + .addEventListener("click", () => handlers.onCyclePreview()); container.querySelector("#toggle-devtools")! .addEventListener("click", () => invoke("toggle_devtools").catch(console.error)); } + +const MODE_GLYPH: Record = { both: "◧◨", edit: "◧", preview: "◨" }; + +/// Reflect the active tab's view mode on the toolbar's View button so the +/// label matches what the per-tab mode icon shows. +export function setToolbarViewMode(container: HTMLElement, mode: string): void { + const b = container.querySelector("#toggle-preview"); + if (b) b.textContent = `View: ${MODE_GLYPH[mode] ?? "◧◨"}`; +} + +/// Reflect the current theme selection on the toolbar dropdown. +export function setToolbarTheme(container: HTMLElement, theme: string): void { + const s = container.querySelector("#theme-select"); + if (s) s.value = theme; +} + +/// Reflect the Vim on/off state on the toolbar toggle button. +export function setToolbarVim(container: HTMLElement, enabled: boolean): void { + const b = container.querySelector("#toggle-vim"); + if (b) { + b.textContent = `Vim: ${enabled ? "on" : "off"}`; + b.setAttribute("aria-pressed", String(enabled)); + b.style.borderColor = enabled ? "#1577c4" : "#666"; + b.style.color = enabled ? "#fff" : "#ccc"; + } +} diff --git a/scrybe-mcp-server/src/tools.rs b/scrybe-mcp-server/src/tools.rs index 60e9018..e819bbb 100644 --- a/scrybe-mcp-server/src/tools.rs +++ b/scrybe-mcp-server/src/tools.rs @@ -3,8 +3,10 @@ //! Tool registry for the Scrybe MCP server. //! -//! Exposes 9 tools to MCP clients: open, read, section, edit, find, -//! render, embed, extract, lint. +//! Exposes the Scrybe tool surface to MCP clients: open, read, section, +//! edit, find, render, embed, extract, lint, logs, quit, close_tab, +//! reload, plus the UI-control parity tools state, set_theme, view_mode, +//! set_vim, and export (each mirrors a human control in scrybe-app). use scrybe_core::{Document, Node, Workspace}; use scrybe_render::{render_html, Theme}; @@ -26,6 +28,11 @@ pub const TOOL_NAMES: &[&str] = &[ "quit", "close_tab", "reload", + "state", + "set_theme", + "view_mode", + "set_vim", + "export", ]; /// Path shared between the Tauri app's `log_append` command and this tool. @@ -192,6 +199,57 @@ impl ToolRegistry { "tail": {"type": "integer", "minimum": 1, "maximum": 1000, "description": "Max lines to return from the end of the log (default 50)"} } } + }, + { + "name": "state", + "description": "Report the running Scrybe app's current UI state: the active tab's path/title/dirty flag, view mode, theme, and whether Vim mode is on. Human equivalent: the path bar, tab mode icon, theme dropdown, and Vim toggle.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "set_theme", + "description": "Set the editor + preview theme in the running Scrybe app. Human equivalent: the toolbar theme dropdown.", + "inputSchema": { + "type": "object", + "properties": { + "theme": {"type": "string", "enum": ["default", "dark", "solarized"]} + }, + "required": ["theme"] + } + }, + { + "name": "view_mode", + "description": "Set the active tab's view mode in the running Scrybe app. Human equivalent: the toolbar View button / per-tab mode icon.", + "inputSchema": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["both", "edit", "preview", "cycle"], "description": "Concrete mode, or 'cycle' to advance both→edit→preview"} + }, + "required": ["mode"] + } + }, + { + "name": "set_vim", + "description": "Enable or disable Vim keybindings in the running Scrybe editor. Human equivalent: the toolbar Vim toggle.", + "inputSchema": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"} + }, + "required": ["enabled"] + } + }, + { + "name": "export", + "description": "Export a Markdown file to a Word (.docx) document with Mermaid diagrams rendered to PNGs (source embedded in PNG metadata). Human equivalent: the toolbar Export button.", + "inputSchema": { + "type": "object", + "properties": { + "input": {"type": "string", "description": "Path to the Markdown file to export"}, + "output": {"type": "string", "description": "Output .docx path (default: input with .docx extension)"}, + "no_diagrams": {"type": "boolean", "description": "Skip Mermaid rendering; keep fenced blocks as monospace text"} + }, + "required": ["input"] + } } ]}) } @@ -212,6 +270,11 @@ impl ToolRegistry { "quit" => self.tool_quit(), "close_tab" => self.tool_close_tab(args), "reload" => self.tool_reload(args), + "state" => self.tool_state(), + "set_theme" => self.tool_set_theme(args), + "view_mode" => self.tool_view_mode(args), + "set_vim" => self.tool_set_vim(args), + "export" => self.tool_export(args), other => json!({"error": format!("unknown tool: {other}")}), } } @@ -560,6 +623,97 @@ impl ToolRegistry { json!({"ok": true, "path": path_str, "bytes": new_source.len()}) } + // ── UI-control parity tools (mirror scrybe-app human controls) ────────── + + /// Report the running app's current UI state (active path, view mode, + /// theme, vim). The app mirrors this to `/tmp/scrybe-state.json` via its + /// `publish_state` command whenever the human changes something. + fn tool_state(&self) -> Value { + const STATE_FILE: &str = "/tmp/scrybe-state.json"; + match std::fs::read_to_string(STATE_FILE) { + Ok(s) => match serde_json::from_str::(&s) { + Ok(v) => v, + Err(e) => json!({"error": format!("invalid state file: {e}")}), + }, + Err(_) => json!({ + "note": "no state available — is scrybe-app running?", + "path": STATE_FILE + }), + } + } + + /// Signal the running app to change the editor + preview theme. The + /// frontend polls `/tmp/scrybe-set-theme.txt` and applies it. + fn tool_set_theme(&self, args: &Value) -> Value { + let theme = match args["theme"].as_str() { + Some(t @ ("default" | "dark" | "solarized")) => t, + Some(other) => return json!({"error": format!("invalid theme: {other}")}), + None => return json!({"error": "theme required"}), + }; + match std::fs::write("/tmp/scrybe-set-theme.txt", theme) { + Ok(_) => json!({"ok": true, "theme": theme}), + Err(e) => json!({"error": e.to_string()}), + } + } + + /// Signal the running app to change the active tab's view mode. + fn tool_view_mode(&self, args: &Value) -> Value { + let mode = match args["mode"].as_str() { + Some(m @ ("both" | "edit" | "preview" | "cycle")) => m, + Some(other) => return json!({"error": format!("invalid mode: {other}")}), + None => return json!({"error": "mode required"}), + }; + match std::fs::write("/tmp/scrybe-view-mode.txt", mode) { + Ok(_) => json!({"ok": true, "mode": mode}), + Err(e) => json!({"error": e.to_string()}), + } + } + + /// Signal the running app to enable/disable Vim keybindings. + fn tool_set_vim(&self, args: &Value) -> Value { + let enabled = match args["enabled"].as_bool() { + Some(b) => b, + None => return json!({"error": "enabled (boolean) required"}), + }; + let signal = if enabled { "on" } else { "off" }; + match std::fs::write("/tmp/scrybe-set-vim.txt", signal) { + Ok(_) => json!({"ok": true, "vim": enabled}), + Err(e) => json!({"error": e.to_string()}), + } + } + + /// Export a Markdown file to Word (.docx) by shelling to `scrybe-docx`. + /// Renders Mermaid blocks to PNGs with the source embedded in metadata. + fn tool_export(&self, args: &Value) -> Value { + let input = match args["input"].as_str() { + Some(p) => p.to_string(), + None => return json!({"error": "input required"}), + }; + let output = match args["output"].as_str() { + Some(o) => o.to_string(), + None => { + let p = std::path::Path::new(&input); + p.with_extension("docx").to_string_lossy().into_owned() + } + }; + let no_diagrams = args["no_diagrams"].as_bool().unwrap_or(false); + + let mut cmd = std::process::Command::new("scrybe-docx"); + cmd.arg(&input).arg("-o").arg(&output); + if no_diagrams { + cmd.arg("--no-diagrams"); + } + match cmd.output() { + Ok(out) if out.status.success() => json!({"ok": true, "output": output}), + Ok(out) => json!({ + "error": String::from_utf8_lossy(&out.stderr).trim().to_string() + }), + Err(e) => json!({ + "error": format!("failed to run scrybe-docx ({e}). Install: pip install scrybe-plugin-docx") + }), + } + } + fn tool_logs(&self, args: &Value) -> Value { let tail = args["tail"].as_u64().unwrap_or(50) as usize; match std::fs::read_to_string(LOG_FILE) { @@ -657,11 +811,40 @@ mod tests { use serde_json::json; #[test] - fn test_list_tools_returns_nine() { + fn test_list_tools_count_matches_registry() { let reg = ToolRegistry::new(); let tools = reg.list_tools_json(); let arr = tools["tools"].as_array().unwrap(); - assert_eq!(arr.len(), 13); + assert_eq!(arr.len(), TOOL_NAMES.len()); + assert_eq!(arr.len(), 18); + } + + #[test] + fn test_set_theme_rejects_invalid() { + let mut reg = ToolRegistry::new(); + let result = reg.call_tool("set_theme", &json!({"theme": "neon"})); + assert!(result["error"].as_str().unwrap().contains("invalid theme")); + } + + #[test] + fn test_view_mode_requires_mode() { + let mut reg = ToolRegistry::new(); + let result = reg.call_tool("view_mode", &json!({})); + assert!(result["error"].is_string()); + } + + #[test] + fn test_set_vim_requires_bool() { + let mut reg = ToolRegistry::new(); + let result = reg.call_tool("set_vim", &json!({"enabled": "yes"})); + assert!(result["error"].is_string()); + } + + #[test] + fn test_export_requires_input() { + let mut reg = ToolRegistry::new(); + let result = reg.call_tool("export", &json!({})); + assert!(result["error"].as_str().unwrap().contains("input required")); } #[test] diff --git a/scrybe-plugin-docx/pyproject.toml b/scrybe-plugin-docx/pyproject.toml index decabfc..7380082 100644 --- a/scrybe-plugin-docx/pyproject.toml +++ b/scrybe-plugin-docx/pyproject.toml @@ -1,10 +1,10 @@ [build-system] requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.backends.legacy:build" +build-backend = "setuptools.build_meta" [project] name = "scrybe-plugin-docx" -version = "0.1.0" +version = "0.2.0" description = "Scrybe plugin — render Markdown to Word (.docx) with embedded Mermaid PNGs" license = {text = "AGPL-3.0-or-later"} requires-python = ">=3.9" @@ -12,6 +12,9 @@ dependencies = [ "python-docx>=1.1", "mistune>=3.0", "Pillow>=10.0", + # Embeds the Mermaid source into rendered diagram PNGs (iTXt), so the + # PNGs in the .docx round-trip back to source via `scrybe_mermaid.extract`. + "scrybe-mermaid>=0.2", ] [project.optional-dependencies] diff --git a/scrybe-plugin-docx/scrybe_plugin_docx/renderer.py b/scrybe-plugin-docx/scrybe_plugin_docx/renderer.py index 8c7d5e0..f028d0e 100644 --- a/scrybe-plugin-docx/scrybe_plugin_docx/renderer.py +++ b/scrybe-plugin-docx/scrybe_plugin_docx/renderer.py @@ -47,6 +47,7 @@ def build(self) -> Document: # Internal AST visitors # --------------------------------------------------------------------------- + class _BlockVisitor: def __init__(self, doc: Document, *, render_diagrams: bool) -> None: self.doc = doc @@ -166,6 +167,7 @@ def _embed_image(self, token: dict) -> None: def _render_mermaid(self, source: str) -> None: try: png = render_mermaid_to_png(source) + png = _embed_mermaid_source(png, source) self.doc.add_picture(io.BytesIO(png), width=Inches(5.5)) except MermaidUnavailable: # Fall back to monospace block. @@ -215,6 +217,20 @@ def _run(self, text: str, *, bold: bool, italic: bool): return run +def _embed_mermaid_source(png: bytes, source: str) -> bytes: + """Embed the Mermaid source into the PNG's iTXt metadata, round-trippable + via `scrybe_mermaid.extract`. Falls back to the unmodified PNG if the + `scrybe_mermaid` binding (or its embed step) is unavailable, so export + never fails just because the codec is missing. + """ + try: + import scrybe_mermaid + + return scrybe_mermaid.embed(png, source) + except Exception: + return png + + def _inline_text(tokens: list[dict]) -> str: """Extract plain text from inline tokens (no formatting). diff --git a/scrybe-plugin-docx/tests/test_renderer.py b/scrybe-plugin-docx/tests/test_renderer.py index 5f5fb0b..82865cb 100644 --- a/scrybe-plugin-docx/tests/test_renderer.py +++ b/scrybe-plugin-docx/tests/test_renderer.py @@ -15,11 +15,11 @@ from scrybe_plugin_docx.renderer import MarkdownToDocx, _inline_text # noqa: E402 - # --------------------------------------------------------------------------- # _inline_text helper # --------------------------------------------------------------------------- + def test_inline_text_plain(): tokens = [{"type": "raw", "raw": "hello world"}] assert _inline_text(tokens) == "hello world" @@ -46,6 +46,7 @@ def test_inline_text_softline(): # MarkdownToDocx.build() # --------------------------------------------------------------------------- + def _build(md: str, **kwargs): return MarkdownToDocx(md, **kwargs).build() @@ -124,8 +125,15 @@ def test_mermaid_no_diagrams_fallback(): def test_mermaid_mmdc_unavailable_falls_back(monkeypatch): """When mmdc is not on PATH, mermaid block falls back to monospace text.""" import scrybe_plugin_docx.mermaid as m - monkeypatch.setattr(m, "render_mermaid_to_png", - lambda _: (_ for _ in ()).throw(m.MermaidUnavailable("no mmdc"))) + import scrybe_plugin_docx.renderer as r + + def _raise(_): + raise m.MermaidUnavailable("no mmdc") + + # renderer.py imports `render_mermaid_to_png` by value, so patch the name + # bound in the renderer module (patching the mermaid module's attribute + # would not affect the already-imported reference). + monkeypatch.setattr(r, "render_mermaid_to_png", _raise) md = "```mermaid\ngraph TD; A-->B\n```" doc = _build(md, render_diagrams=True) @@ -157,6 +165,7 @@ def test_inline_code(): # CLI # --------------------------------------------------------------------------- + def test_cli_stdin(tmp_path, monkeypatch): import io, sys from scrybe_plugin_docx.main import main From 8717ffa742c6cb4ca99a7b008410231b594d185b Mon Sep 17 00:00:00 2001 From: shartsock <33919+hartsock@users.noreply.github.com> Date: Fri, 29 May 2026 15:37:15 -0400 Subject: [PATCH 2/2] fix(app): re-publish MCP state mirror on tab open/switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `state` tool only reflected the active file after a theme/view/vim change, because only those handlers called publishState — opening or switching tabs updated the human path bar but not the agent-visible mirror, so "what am I looking at" could diverge between the two surfaces (violating the control-parity rule). Publish from updatePathBar, which fires on every tab mutation, so both stay in lockstep. Co-Authored-By: Claude Opus 4.8 (1M context) --- scrybe-app/src/main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scrybe-app/src/main.ts b/scrybe-app/src/main.ts index fe659ee..d6da9af 100644 --- a/scrybe-app/src/main.ts +++ b/scrybe-app/src/main.ts @@ -274,9 +274,13 @@ function publishState(): void { }).catch(() => { /* state mirror is best-effort */ }); } -/// Update the selectable path bar to show the active tab's full path. +/// Update the selectable path bar to show the active tab's full path, and +/// re-publish the MCP state mirror so the `state` tool tracks tab opens and +/// switches in real time (not just theme/view/vim changes). Called from +/// `redrawTabs`, which fires on every tab mutation. function updatePathBar(): void { renderPathBar(pathBarEl, state.activeTab()); + publishState(); } /// Export the active tab's current buffer to a Word (.docx) file. Prompts