Step 142: add Emacs package browser

This commit is contained in:
Bill
2026-02-09 17:55:10 -07:00
parent f031004cab
commit beaec58073
8 changed files with 333 additions and 3 deletions

View File

@@ -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. |

View File

@@ -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)

View File

@@ -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;

View File

@@ -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";

View File

@@ -0,0 +1,247 @@
#pragma once
#include "imgui.h"
#include "EmacsIntegration.h"
#include <string>
#include <vector>
#include <unordered_map>
#include <algorithm>
#include <cctype>
struct EmacsPackageEntry {
std::string name;
std::string version;
std::string description;
std::string status; // loaded | available | not-installed
};
struct EmacsPackageBrowserState {
std::vector<EmacsPackageEntry> 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<EmacsPackageEntry> parsePackageListText(const std::string& text) {
std::vector<EmacsPackageEntry> 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<EmacsPackageEntry> mergePackageLists(const std::vector<EmacsPackageEntry>& loaded,
const std::vector<EmacsPackageEntry>& available,
bool includeAvailable) {
std::unordered_map<std::string, EmacsPackageEntry> 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<EmacsPackageEntry> 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<EmacsPackageEntry> 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());
}
}

View File

@@ -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)
// ---------------------------------------------------------------

View File

@@ -0,0 +1,55 @@
// Step 142 TDD Test: Emacs package browser
#include "EmacsPackageBrowser.h"
#include "EmacsIntegration.h"
#include <iostream>
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<EmacsPackageEntry>& 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;
}

View File

@@ -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)`