diff --git a/PROGRESS.md b/PROGRESS.md index c8d5590..2579593 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -489,3 +489,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-09 | Codex | Step 141: Emacs daemon startup uses user config path with init logging and diagnostics. 4/4 tests pass. | | 2026-02-09 | Codex | Added FEATURE_REQUESTS.md to track security vulnerability awareness and semantic library annotations for future sprints. | | 2026-02-09 | Codex | Added LLM tooling + MCP bridge feature request to FEATURE_REQUESTS.md. | +| 2026-02-10 | Codex | Step 142: Emacs package browser panel with loaded/available status, load actions, and package queries via emacsclient. 7/7 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 102d593..d36baf5 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -782,6 +782,10 @@ add_executable(step141_test tests/step141_test.cpp) target_include_directories(step141_test PRIVATE src) target_link_libraries(step141_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step142_test tests/step142_test.cpp) +target_include_directories(step142_test PRIVATE src) +target_link_libraries(step142_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) + 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 ca2d770..112b393 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -52,6 +52,7 @@ #include "CompositionPanel.h" #include "IncrementalOptimizer.h" #include "EmacsIntegration.h" +#include "EmacsPackageBrowser.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -179,6 +180,8 @@ struct EditorState { LibraryBrowserState libraryBrowser; bool showCompositionPanel = false; CompositionPanelState compositionPanel; + bool showEmacsPackagesPanel = false; + EmacsPackageBrowserState emacsPackages; struct LibraryIndexRequest { std::string name; std::string version; diff --git a/editor/src/EmacsIntegration.h b/editor/src/EmacsIntegration.h index 282e422..a5e5641 100644 --- a/editor/src/EmacsIntegration.h +++ b/editor/src/EmacsIntegration.h @@ -133,7 +133,7 @@ public: } // Send command to Emacs and get result - std::string sendCommand(const std::string& elispCommand) { + virtual std::string sendCommand(const std::string& elispCommand) { lastError_.clear(); if (!daemonRunning_ && !isDaemonAlive()) { @@ -255,7 +255,7 @@ public: bool isDaemonAlive() const { return daemonRunning_; } - std::string sendCommand(const std::string& elispCommand) { + std::string sendCommand(const std::string& elispCommand) override { lastSentCommand_ = elispCommand; // Simulate responses for known commands @@ -268,6 +268,17 @@ public: if (elispCommand == "(+ 1 1)") { return "2"; } + if (elispCommand.find("package-alist") != std::string::npos) { + return "use-package|2.4.1|Declarative package configuration\n" + "cl-lib|1.0|Common Lisp extensions for Emacs"; + } + if (elispCommand.find("package-archive-contents") != std::string::npos) { + return "magit|3.4.0|Git porcelain inside Emacs\n" + "projectile|2.7.0|Project interaction library"; + } + if (elispCommand.find("(require '") != std::string::npos) { + return "t"; + } // Unknown command — simulate error lastError_ = "void-function"; diff --git a/editor/src/EmacsPackageBrowser.h b/editor/src/EmacsPackageBrowser.h new file mode 100644 index 0000000..1ef5b84 --- /dev/null +++ b/editor/src/EmacsPackageBrowser.h @@ -0,0 +1,247 @@ +#pragma once +#include "imgui.h" +#include "EmacsIntegration.h" +#include +#include +#include +#include +#include + +struct EmacsPackageEntry { + std::string name; + std::string version; + std::string description; + std::string status; // loaded | available | not-installed +}; + +struct EmacsPackageBrowserState { + std::vector packages; + char filterBuf[128] = {}; + char loadBuf[128] = {}; + int selected = -1; + bool showAvailable = true; + bool needsRefresh = true; + std::string lastError; +}; + +static inline std::string trimCopy(const std::string& s) { + size_t start = 0; + while (start < s.size() && std::isspace((unsigned char)s[start])) ++start; + size_t end = s.size(); + while (end > start && std::isspace((unsigned char)s[end - 1])) --end; + return s.substr(start, end - start); +} + +static inline std::string toLowerCopyPkg(const std::string& s) { + std::string out = s; + std::transform(out.begin(), out.end(), out.begin(), + [](unsigned char c) { return (char)std::tolower(c); }); + return out; +} + +static std::string buildPackageListCommand(const std::string& listSymbol) { + std::string cmd = + "(progn " + "(require 'package) " + "(mapconcat " + " (lambda (p) " + " (let* ((desc (cond " + " ((and (consp (cdr p)) (listp (cdr p))) (car (cdr p))) " + " ((consp (cdr p)) (cdr p)) " + " (t nil))) " + " (name (symbol-name (car p))) " + " (ver (if (and desc (fboundp 'package-desc-version)) " + " (package-version-join (package-desc-version desc)) \"\")) " + " (summary (if (and desc (fboundp 'package-desc-summary)) " + " (package-desc-summary desc) \"\"))) " + " (concat name \"|\" ver \"|\" summary))) " + + listSymbol + + " \"\\n\"))"; + return cmd; +} + +static std::vector parsePackageListText(const std::string& text) { + std::vector out; + if (text.empty()) return out; + size_t start = 0; + while (start < text.size()) { + size_t end = text.find('\n', start); + if (end == std::string::npos) end = text.size(); + std::string line = trimCopy(text.substr(start, end - start)); + if (!line.empty()) { + size_t a = line.find('|'); + size_t b = a == std::string::npos ? std::string::npos : line.find('|', a + 1); + EmacsPackageEntry entry; + if (a == std::string::npos) { + entry.name = line; + } else { + entry.name = trimCopy(line.substr(0, a)); + if (b == std::string::npos) { + entry.version = trimCopy(line.substr(a + 1)); + } else { + entry.version = trimCopy(line.substr(a + 1, b - a - 1)); + entry.description = trimCopy(line.substr(b + 1)); + } + } + if (!entry.name.empty()) { + out.push_back(std::move(entry)); + } + } + start = end + 1; + } + return out; +} + +static std::vector mergePackageLists(const std::vector& loaded, + const std::vector& available, + bool includeAvailable) { + std::unordered_map merged; + for (auto entry : loaded) { + entry.status = "loaded"; + merged[entry.name] = std::move(entry); + } + if (includeAvailable) { + for (auto entry : available) { + auto it = merged.find(entry.name); + if (it == merged.end()) { + entry.status = "available"; + merged[entry.name] = std::move(entry); + } else if (it->second.description.empty() && !entry.description.empty()) { + it->second.description = entry.description; + } + } + } + std::vector out; + out.reserve(merged.size()); + for (auto& kv : merged) out.push_back(std::move(kv.second)); + std::sort(out.begin(), out.end(), + [](const EmacsPackageEntry& a, const EmacsPackageEntry& b) { + return a.name < b.name; + }); + return out; +} + +static void refreshEmacsPackages(EmacsPackageBrowserState& state, + EmacsConnection& emacs, + std::string& logOut) { + state.lastError.clear(); + std::string loadedText = emacs.sendCommand(buildPackageListCommand("package-alist")); + if (loadedText.empty() && !emacs.getLastError().empty()) { + state.lastError = emacs.getLastError(); + logOut += "[emacs] " + state.lastError + "\n"; + } + auto loaded = parsePackageListText(loadedText); + std::vector available; + if (state.showAvailable) { + std::string availText = emacs.sendCommand(buildPackageListCommand("package-archive-contents")); + if (availText.empty() && !emacs.getLastError().empty()) { + state.lastError = emacs.getLastError(); + logOut += "[emacs] " + state.lastError + "\n"; + } + available = parsePackageListText(availText); + } + state.packages = mergePackageLists(loaded, available, state.showAvailable); + state.needsRefresh = false; +} + +static bool loadEmacsPackage(EmacsConnection& emacs, + const std::string& packageName, + std::string& logOut) { + if (packageName.empty()) return false; + std::string cmd = "(require '" + packageName + ")"; + std::string result = emacs.sendCommand(cmd); + if (!emacs.getLastError().empty() && result == "error") { + logOut += "[emacs] Failed to load " + packageName + ": " + emacs.getLastError() + "\n"; + return false; + } + logOut += "[emacs] Loaded package: " + packageName + "\n"; + return true; +} + +static bool emacsPackageMatchesFilter(const EmacsPackageEntry& entry, const std::string& filter) { + if (filter.empty()) return true; + std::string hay = toLowerCopyPkg(entry.name + " " + entry.description); + std::string needle = toLowerCopyPkg(filter); + return hay.find(needle) != std::string::npos; +} + +static void renderEmacsPackageBrowser(EmacsPackageBrowserState& state, + EmacsConnection& emacs, + std::string& logOut) { + if (ImGui::Button("Refresh")) { + state.needsRefresh = true; + } + ImGui::SameLine(); + if (ImGui::Checkbox("Show Available", &state.showAvailable)) { + state.needsRefresh = true; + } + ImGui::SameLine(); + ImGui::InputText("Filter", state.filterBuf, sizeof(state.filterBuf)); + + if (state.needsRefresh) { + refreshEmacsPackages(state, emacs, logOut); + } + + ImGui::Separator(); + if (state.packages.empty()) { + ImGui::TextDisabled("(no packages loaded)"); + } else { + if (ImGui::BeginTable("##emacsPkgs", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders)) { + ImGui::TableSetupColumn("Package", ImGuiTableColumnFlags_WidthStretch, 0.25f); + ImGui::TableSetupColumn("Version", ImGuiTableColumnFlags_WidthStretch, 0.15f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 0.15f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch, 0.45f); + ImGui::TableHeadersRow(); + + std::string filter = state.filterBuf; + int row = 0; + for (int i = 0; i < (int)state.packages.size(); ++i) { + const auto& entry = state.packages[i]; + if (!emacsPackageMatchesFilter(entry, filter)) continue; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + bool selected = (state.selected == i); + if (ImGui::Selectable(entry.name.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns)) { + state.selected = i; + } + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(entry.version.empty() ? "-" : entry.version.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(entry.status.empty() ? "-" : entry.status.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextWrapped("%s", entry.description.empty() ? "-" : entry.description.c_str()); + ++row; + } + if (row == 0) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("(no matches)"); + } + ImGui::EndTable(); + } + } + + ImGui::Separator(); + ImGui::InputText("Load Package", state.loadBuf, sizeof(state.loadBuf)); + ImGui::SameLine(); + std::string loadTarget = state.loadBuf; + if (loadTarget.empty() && state.selected >= 0 && + state.selected < (int)state.packages.size()) { + loadTarget = state.packages[state.selected].name; + } + if (ImGui::Button("Load")) { + if (loadTarget.empty()) { + logOut += "[emacs] No package selected.\n"; + } else { + bool ok = loadEmacsPackage(emacs, loadTarget, logOut); + if (!ok) { + logOut += "[emacs] Package not installed: " + loadTarget + "\n"; + } + state.needsRefresh = true; + } + } + if (!loadTarget.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", loadTarget.c_str()); + } +} diff --git a/editor/src/main.cpp b/editor/src/main.cpp index cf8a00b..f8ede42 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -308,6 +308,7 @@ int main(int, char**) { ImGui::MenuItem("Dependencies", nullptr, &state.showDependencyPanel); ImGui::MenuItem("Libraries", nullptr, &state.showLibraryBrowserPanel); ImGui::MenuItem("Compose", nullptr, &state.showCompositionPanel); + ImGui::MenuItem("Emacs Packages", nullptr, &state.showEmacsPackagesPanel); ImGui::MenuItem("Settings", nullptr, &state.showSettingsPanel); ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings); if (state.active()) { @@ -610,6 +611,14 @@ int main(int, char**) { ImGui::End(); } + if (state.showEmacsPackagesPanel) { + ImGui::Begin("Emacs Packages", &state.showEmacsPackagesPanel); + ImGui::PushFont(uiFont); + renderEmacsPackageBrowser(state.emacsPackages, state.emacs, state.outputLog); + ImGui::PopFont(); + ImGui::End(); + } + // --------------------------------------------------------------- // Find / Replace bar (floating at top of editor) // --------------------------------------------------------------- diff --git a/editor/tests/step142_test.cpp b/editor/tests/step142_test.cpp new file mode 100644 index 0000000..3673272 --- /dev/null +++ b/editor/tests/step142_test.cpp @@ -0,0 +1,55 @@ +// Step 142 TDD Test: Emacs package browser +#include "EmacsPackageBrowser.h" +#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; + } +} + +static const EmacsPackageEntry* findPkg(const std::vector& pkgs, + const std::string& name) { + for (const auto& pkg : pkgs) { + if (pkg.name == name) return &pkg; + } + return nullptr; +} + +int main() { + int passed = 0; + int failed = 0; + + MockEmacsConnection mock; + EmacsPackageBrowserState state; + std::string log; + + refreshEmacsPackages(state, mock, log); + expect(!state.packages.empty(), "packages loaded", passed, failed); + + const EmacsPackageEntry* usePkg = findPkg(state.packages, "use-package"); + expect(usePkg != nullptr, "loaded package listed", passed, failed); + if (usePkg) { + expect(usePkg->status == "loaded", "loaded status set", passed, failed); + } + + const EmacsPackageEntry* magitPkg = findPkg(state.packages, "magit"); + expect(magitPkg != nullptr, "available package listed", passed, failed); + if (magitPkg) { + expect(magitPkg->status == "available", "available status set", passed, failed); + } + + bool loadOk = loadEmacsPackage(mock, "magit", log); + expect(loadOk, "load package command", passed, failed); + expect(mock.getLastSentCommand().find("(require 'magit)") != std::string::npos, + "require command sent", passed, failed); + + std::cout << "\n=== Step 142 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index 4ea1f21..ef9ee00 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -179,7 +179,7 @@ functionality through the Whetstone UI. Startup log shown in Output panel. Errors in init.el reported as diagnostics. *Modifies:* `EmacsIntegration.h`, `Orchestrator.h` -- [ ] **Step 142: Elisp package browser** +- [x] **Step 142: Elisp package browser** New panel section for Emacs users: "Emacs Packages". Lists loaded packages from the running daemon (query via `package-alist`). Shows: package name, version, description. "Load Package" button sends `(require 'package-name)`