Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions scrybe-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
</div>
<div id="editor-col">
<div id="tab-bar"></div>
<div id="path-bar"></div>
<div id="editor-and-preview">
<div id="editor"></div>
<div id="preview"></div>
Expand Down
27 changes: 27 additions & 0 deletions scrybe-app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions scrybe-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 94 additions & 0 deletions scrybe-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> {
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<String> {
poll_signal("/tmp/scrybe-view-mode.txt")
}

/// Poll for an MCP `set_vim` signal (`on` | `off`).
#[tauri::command]
fn poll_set_vim() -> Option<String> {
poll_signal("/tmp/scrybe-set-vim.txt")
}

/// Locate the `scrybe-docx` CLI (from the `scrybe-plugin-docx` package).
fn which_scrybe_docx() -> Result<String, String> {
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<String, String> {
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()
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions scrybe-app/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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([]),
Expand All @@ -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
Expand Down
Loading
Loading