From ca9628edfd79c508c3ab3ecb10c6a54bc0a18b2f Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 18:29:36 -0700 Subject: [PATCH] Step 146: add Emacs buffer bridge --- PROGRESS.md | 1 + editor/CMakeLists.txt | 4 +++ editor/src/EditorState.h | 43 ++++++++++++++++++++++++++++++ editor/src/EmacsIntegration.h | 50 ++++++++++++++++++++++++++++++++++- editor/src/main.cpp | 25 ++++++++++++++++++ editor/tests/step146_test.cpp | 38 ++++++++++++++++++++++++++ sprint5_plan.md | 2 +- 7 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 editor/tests/step146_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index fd89c2e..31895f2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -493,3 +493,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 143: Elisp function discovery via apropos/describe-function, Emacs function indexing into ExternalModule + LibraryIndex, and elisp primitive updates. 7/7 tests pass. | | 2026-02-10 | Codex | Step 144: Emacs keybinding integration (prefix handling, M-x minibuffer, mode line display, key-binding lookup via daemon). 11/11 tests pass. | | 2026-02-10 | Codex | Step 145: Org-mode rendering with headings/blocks, editable source blocks, inline results, org temp runner, and tree-sitter-org integration. 11/11 tests pass. | +| 2026-02-10 | Codex | Step 146: Emacs-Whetstone bridge with buffer pull/push, Emacs frame opening, and sync commands. 5/5 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 6c1cc90..33049bb 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -819,6 +819,10 @@ add_executable(step145_test tests/step145_test.cpp) target_include_directories(step145_test PRIVATE src) target_link_libraries(step145_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui tree_sitter_org) +add_executable(step146_test tests/step146_test.cpp) +target_include_directories(step146_test PRIVATE src) +target_link_libraries(step146_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 051ad03..8d111ca 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -188,6 +188,7 @@ struct EditorState { EmacsFunctionIndex emacsFunctionIndex; bool emacsFunctionIndexDirty = true; EmacsKeybindingState emacsKeys; + bool showEmacsBridgePanel = false; bool showOrgPanel = true; OrgDocumentState orgDoc; int orgTempCounter = 0; @@ -1434,6 +1435,48 @@ struct EditorState { updateEmacsModeLine(emacsKeys, emacs, nowSeconds, outputLog); } + void pullFromEmacs() { + if (!active()) return; + if (active()->path.rfind("(untitled", 0) == 0) { + outputLog += "[emacs] Save file before syncing.\n"; + return; + } + std::string text = emacs.getBufferText(active()->path); + if (!emacs.getLastError().empty() && text == "error") { + outputLog += "[emacs] " + emacs.getLastError() + "\n"; + return; + } + active()->editBuf = text; + onTextChanged(); + outputLog += "[emacs] Pulled buffer from Emacs.\n"; + } + + void pushToEmacs() { + if (!active()) return; + if (active()->path.rfind("(untitled", 0) == 0) { + outputLog += "[emacs] Save file before syncing.\n"; + return; + } + if (!emacs.setBufferText(active()->path, active()->editBuf)) { + outputLog += "[emacs] " + emacs.getLastError() + "\n"; + return; + } + outputLog += "[emacs] Pushed buffer to Emacs.\n"; + } + + void openInEmacsFrame() { + if (!active()) return; + if (active()->path.rfind("(untitled", 0) == 0) { + outputLog += "[emacs] Save file before opening in Emacs.\n"; + return; + } + if (!emacs.openFileInEmacsFrame(active()->path)) { + outputLog += "[emacs] " + emacs.getLastError() + "\n"; + return; + } + outputLog += "[emacs] Opened in Emacs frame.\n"; + } + std::string buildRunCommand(const std::string& path, const std::string& language, bool buildOnly) const { diff --git a/editor/src/EmacsIntegration.h b/editor/src/EmacsIntegration.h index a4669fc..f96002b 100644 --- a/editor/src/EmacsIntegration.h +++ b/editor/src/EmacsIntegration.h @@ -106,6 +106,20 @@ public: static std::string modeLine() { return "(format-mode-line mode-line-format)"; } + + // Build an Elisp expression to read a buffer for a given file + static std::string bufferStringForFile(const std::string& path) { + return "(with-current-buffer (find-file-noselect \"" + + escapeString(path) + "\") (buffer-string))"; + } + + // Build an Elisp expression to replace a buffer's contents and save + static std::string replaceBufferForFile(const std::string& path, const std::string& text) { + return "(with-current-buffer (find-file-noselect \"" + escapeString(path) + "\") " + "(erase-buffer) " + "(insert \"" + escapeString(text) + "\") " + "(save-buffer))"; + } }; // --------------------------------------------------------------------------- @@ -213,6 +227,27 @@ public: return output; } + std::string getBufferText(const std::string& path) { + return sendCommand(ElispCommandBuilder::bufferStringForFile(path)); + } + + bool setBufferText(const std::string& path, const std::string& text) { + std::string result = sendCommand(ElispCommandBuilder::replaceBufferForFile(path, text)); + if (!lastError_.empty() && result == "error") return false; + return true; + } + + bool openFileInEmacsFrame(const std::string& path) { + lastError_.clear(); + std::string cmd = emacsclientPath_ + " -c -n \"" + path + "\""; + int code = runCommand(cmd); + if (code != 0) { + lastError_ = "emacsclient failed to open frame"; + return false; + } + return true; + } + // Auto-restart daemon if it died bool ensureDaemon() { if (daemonRunning_ && isDaemonAlive()) return true; @@ -300,6 +335,12 @@ public: lastSentCommand_ = elispCommand; // Simulate responses for known commands + if (elispCommand.find("buffer-string") != std::string::npos) { + return "mock-buffer"; + } + if (elispCommand.find("erase-buffer") != std::string::npos) { + return "t"; + } if (elispCommand.find("find-file") != std::string::npos) { return "t"; // success } @@ -356,7 +397,6 @@ public: if (elispCommand.find("format-mode-line") != std::string::npos) { return "Emacs: Fundamental (Whetstone)"; } - // Unknown command — simulate error lastError_ = "void-function"; return "error"; @@ -372,10 +412,18 @@ public: // Test helper: get the last command sent std::string getLastSentCommand() const { return lastSentCommand_; } std::string getLastInitPath() const { return lastInitPath_; } + std::string getLastRunCommand() const { return lastRunCommand_; } + +protected: + int runCommand(const std::string& cmd) const override { + lastRunCommand_ = cmd; + return 0; + } private: std::string lastError_; std::string lastSentCommand_; std::string lastInitPath_; + mutable std::string lastRunCommand_; }; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index c0dacc0..e65d649 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -360,6 +360,7 @@ int main(int, char**) { ImGui::MenuItem("Libraries", nullptr, &state.showLibraryBrowserPanel); ImGui::MenuItem("Compose", nullptr, &state.showCompositionPanel); ImGui::MenuItem("Emacs Packages", nullptr, &state.showEmacsPackagesPanel); + ImGui::MenuItem("Emacs Bridge", nullptr, &state.showEmacsBridgePanel); ImGui::MenuItem("Settings", nullptr, &state.showSettingsPanel); ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings); if (state.active()) { @@ -674,6 +675,30 @@ int main(int, char**) { ImGui::End(); } + if (state.showEmacsBridgePanel) { + ImGui::Begin("Emacs Bridge", &state.showEmacsBridgePanel); + ImGui::PushFont(uiFont); + if (state.active()) { + ImGui::Text("Active file: %s", state.active()->path.c_str()); + } else { + ImGui::TextDisabled("(no active buffer)"); + } + ImGui::Separator(); + if (ImGui::Button("Open Emacs Frame")) { + state.openInEmacsFrame(); + } + ImGui::SameLine(); + if (ImGui::Button("Pull From Emacs")) { + state.pullFromEmacs(); + } + ImGui::SameLine(); + if (ImGui::Button("Push To Emacs")) { + state.pushToEmacs(); + } + ImGui::PopFont(); + ImGui::End(); + } + if (state.layoutPreset == LayoutPreset::Emacs && state.emacsKeys.minibufferActive) { ImGuiWindowFlags mbFlags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | diff --git a/editor/tests/step146_test.cpp b/editor/tests/step146_test.cpp new file mode 100644 index 0000000..4cb5368 --- /dev/null +++ b/editor/tests/step146_test.cpp @@ -0,0 +1,38 @@ +// Step 146 TDD Test: Emacs-Whetstone bridge +#include "EmacsIntegration.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + MockEmacsConnection mock; + std::string path = "C:/tmp/example.el"; + + std::string text = mock.getBufferText(path); + expect(text == "mock-buffer", "buffer text pulled", passed, failed); + + bool setOk = mock.setBufferText(path, "(message \"hi\")"); + expect(setOk, "buffer text pushed", passed, failed); + expect(mock.getLastSentCommand().find("erase-buffer") != std::string::npos, + "replace buffer command", passed, failed); + + bool frameOk = mock.openFileInEmacsFrame(path); + expect(frameOk, "open emacs frame", passed, failed); + expect(mock.getLastRunCommand().find("emacsclient") != std::string::npos, + "emacsclient command", passed, failed); + + std::cout << "\n=== Step 146 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index c868431..5bb67e8 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -211,7 +211,7 @@ functionality through the Whetstone UI. Results rendered inline (like Jupyter notebooks). *New:* tree-sitter-org grammar, `OrgMode.h` -- [ ] **Step 146: Emacs-Whetstone bridge** +- [x] **Step 146: Emacs-Whetstone bridge** Bidirectional sync: Emacs buffers ↔ Whetstone buffers. When the user has the Emacs layout active, they can switch to pure Emacs (daemon connected to a graphical frame) for operations Whetstone doesn't