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..d6da9af 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,112 @@ 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, 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
+/// 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 +386,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 +397,7 @@ function redrawTabs(): void {
() => newTab(),
id => { applyViewMode(state.cycleViewMode(id)); redrawTabs(); },
);
+ updatePathBar();
}
function selectTab(id: string): void {
@@ -508,6 +613,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 +645,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