Step 169: notification system

This commit is contained in:
Bill
2026-02-09 21:26:10 -07:00
parent 08b7d71a38
commit 454bd5eb8a
22 changed files with 637 additions and 167 deletions

View File

@@ -516,3 +516,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step
| 2026-02-10 | Codex | Added `file_limits_test` to enforce architecture file size limits (with temporary allowlist for known oversize headers). 4/4 tests pass. |
| 2026-02-10 | Codex | Step 167: Split EditorState into focused sub-states (Search/Agent/Build/Library/Emacs/UI), updated panels/handlers, and added step167_test. 1/1 tests pass. |
| 2026-02-10 | Codex | Step 168: Split oversized editor/AST component headers (CodeEditorWidget, SyntaxHighlighter, Parser, CppGenerator) with new step168 unit + integration tests. 79/79 tests pass (step168_test 75/75, step168_integration_test 4/4). file_limits_test 4/4 passes. |
| 2026-02-10 | Codex | Step 169: Notification/toast system with status bar history, output log rewire, and notification tests. 2/2 tests pass (step169_test, step169_integration_test). file_limits_test 4/4 passes. |

View File

@@ -967,6 +967,14 @@ target_link_libraries(step168_integration_test PRIVATE
tree_sitter_rust
tree_sitter_go)
add_executable(step169_test tests/step169_test.cpp)
target_include_directories(step169_test PRIVATE src)
target_link_libraries(step169_test PRIVATE imgui::imgui)
add_executable(step169_integration_test tests/step169_integration_test.cpp)
target_include_directories(step169_integration_test PRIVATE src)
target_link_libraries(step169_integration_test PRIVATE imgui::imgui)
find_package(SDL2 CONFIG REQUIRED)
find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED)

View File

@@ -3,6 +3,7 @@
#include "imgui.h"
#include "AgentRegistry.h"
#include "NotificationSystem.h"
#include <string>
#include <algorithm>
@@ -30,7 +31,7 @@ static bool agentMatchesFilter(const AgentDescriptor& agent,
static void renderAgentMarketplace(AgentMarketplaceState& state,
AgentRegistry& registry,
std::string& outputLog,
NotificationSystem& notifications,
const std::string& workspaceRoot) {
if (state.registryPathBuf[0] == '\0') {
std::string path = workspaceRoot.empty() ? "agents.json"
@@ -46,30 +47,35 @@ static void renderAgentMarketplace(AgentMarketplaceState& state,
ImGui::InputText("Registry URL", state.registryUrlBuf, sizeof(state.registryUrlBuf));
if (ImGui::Button("Set URL")) {
registry.setRegistryUrl(state.registryUrlBuf);
outputLog += "[agents] Registry URL set.\n";
notifications.notify(NotificationLevel::Info, "[agents] Registry URL set.");
}
ImGui::SameLine();
if (ImGui::Button("Refresh")) {
outputLog += "[agents] STUB: refresh registry from " +
std::string(state.registryUrlBuf) + "\n";
notifications.notify(NotificationLevel::Info,
"[agents] STUB: refresh registry from " +
std::string(state.registryUrlBuf));
}
ImGui::InputText("Registry File", state.registryPathBuf, sizeof(state.registryPathBuf));
if (ImGui::Button("Load")) {
std::string error;
if (!registry.loadFromFile(state.registryPathBuf, error)) {
outputLog += "[agents] Load failed: " + error + "\n";
notifications.notify(NotificationLevel::Error,
"[agents] Load failed: " + error);
} else {
outputLog += "[agents] Loaded registry.\n";
notifications.notify(NotificationLevel::Success,
"[agents] Loaded registry.");
}
}
ImGui::SameLine();
if (ImGui::Button("Save")) {
std::string error;
if (!registry.saveToFile(state.registryPathBuf, error)) {
outputLog += "[agents] Save failed: " + error + "\n";
notifications.notify(NotificationLevel::Error,
"[agents] Save failed: " + error);
} else {
outputLog += "[agents] Saved registry.\n";
notifications.notify(NotificationLevel::Success,
"[agents] Saved registry.");
}
}
@@ -114,13 +120,15 @@ static void renderAgentMarketplace(AgentMarketplaceState& state,
if (!selected->installed) {
if (ImGui::Button("Install Agent")) {
selected->installed = true;
outputLog += "[agents] Installed " + selected->name +
" (" + selected->endpoint + ")\n";
notifications.notify(NotificationLevel::Success,
"[agents] Installed " + selected->name +
" (" + selected->endpoint + ")");
}
} else {
if (ImGui::Button("Uninstall Agent")) {
selected->installed = false;
outputLog += "[agents] Uninstalled " + selected->name + "\n";
notifications.notify(NotificationLevel::Success,
"[agents] Uninstalled " + selected->name);
}
}
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include "imgui.h"
#include "CompositionBuilder.h"
#include "NotificationSystem.h"
#include <string>
#include <vector>
#include <cstdio>
@@ -17,7 +18,7 @@ struct CompositionPanelState {
static bool renderCompositionPanel(CompositionPanelState& state,
const std::vector<PrimitiveSymbol>& primitives,
std::string& outCode,
std::string& outputLog) {
NotificationSystem& notifications) {
outCode.clear();
ImGui::InputText("Input Type", state.inputType, sizeof(state.inputType));
ImGui::InputText("Output Type", state.outputType, sizeof(state.outputType));
@@ -48,7 +49,8 @@ static bool renderCompositionPanel(CompositionPanelState& state,
if (state.selected >= 0 && state.selected < (int)suggestions.size()) {
if (ImGui::Button("Add Step")) {
if (state.steps.size() >= 8) {
outputLog += "[compose] Max 8 steps.\n";
notifications.notify(NotificationLevel::Warning,
"[compose] Max 8 steps.");
} else {
state.steps.push_back(suggestions[state.selected]);
}
@@ -87,7 +89,8 @@ static bool renderCompositionPanel(CompositionPanelState& state,
ImGui::EndChild();
if (ImGui::Button("Insert Code")) {
outCode = code;
outputLog += "[compose] Inserted pipeline.\n";
notifications.notify(NotificationLevel::Success,
"[compose] Inserted pipeline.");
return true;
}
}

View File

@@ -2,6 +2,7 @@
#include "imgui.h"
#include "DependencyParser.h"
#include "PackageRegistry.h"
#include "NotificationSystem.h"
#include <filesystem>
#include <fstream>
#include <cstdio>
@@ -132,23 +133,25 @@ static std::string formatRequirementLine(const DependencySpec& dep) {
static bool writeRequirementsFile(const std::string& path,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
std::ofstream out(path);
if (!out.is_open()) {
logOut += "[deps] Failed to write " + path + "\n";
notifications.notify(NotificationLevel::Error,
"[deps] Failed to write " + path);
return false;
}
for (const auto& dep : deps) {
if (dep.name.empty()) continue;
out << formatRequirementLine(dep) << "\n";
}
logOut += "[deps] Wrote " + path + "\n";
notifications.notify(NotificationLevel::Success,
"[deps] Wrote " + path);
return true;
}
static bool writePackageJson(const std::string& path,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
nlohmann::json j;
if (std::filesystem::exists(path)) {
std::ifstream in(path);
@@ -175,17 +178,19 @@ static bool writePackageJson(const std::string& path,
std::ofstream out(path);
if (!out.is_open()) {
logOut += "[deps] Failed to write " + path + "\n";
notifications.notify(NotificationLevel::Error,
"[deps] Failed to write " + path);
return false;
}
out << j.dump(2);
logOut += "[deps] Wrote " + path + "\n";
notifications.notify(NotificationLevel::Success,
"[deps] Wrote " + path);
return true;
}
static bool writeCargoToml(const std::string& path,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
std::vector<std::string> prefix;
bool foundDeps = false;
if (std::filesystem::exists(path)) {
@@ -201,7 +206,8 @@ static bool writeCargoToml(const std::string& path,
}
std::ofstream out(path);
if (!out.is_open()) {
logOut += "[deps] Failed to write " + path + "\n";
notifications.notify(NotificationLevel::Error,
"[deps] Failed to write " + path);
return false;
}
for (const auto& line : prefix) {
@@ -214,13 +220,14 @@ static bool writeCargoToml(const std::string& path,
std::string version = dep.version.empty() ? "*" : dep.version;
out << dep.name << " = \"" << version << "\"\n";
}
logOut += "[deps] Wrote " + path + "\n";
notifications.notify(NotificationLevel::Success,
"[deps] Wrote " + path);
return true;
}
static bool writeGoMod(const std::string& path,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
std::string moduleLine;
std::string goLine;
if (std::filesystem::exists(path)) {
@@ -236,7 +243,8 @@ static bool writeGoMod(const std::string& path,
std::ofstream out(path);
if (!out.is_open()) {
logOut += "[deps] Failed to write " + path + "\n";
notifications.notify(NotificationLevel::Error,
"[deps] Failed to write " + path);
return false;
}
out << moduleLine << "\n" << goLine << "\n\n";
@@ -247,13 +255,14 @@ static bool writeGoMod(const std::string& path,
out << " " << dep.name << " " << version << "\n";
}
out << ")\n";
logOut += "[deps] Wrote " + path + "\n";
notifications.notify(NotificationLevel::Success,
"[deps] Wrote " + path);
return true;
}
static bool writeVcpkgJson(const std::string& path,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
nlohmann::json j;
if (std::filesystem::exists(path)) {
std::ifstream in(path);
@@ -274,39 +283,43 @@ static bool writeVcpkgJson(const std::string& path,
j["dependencies"] = depsArr;
std::ofstream out(path);
if (!out.is_open()) {
logOut += "[deps] Failed to write " + path + "\n";
notifications.notify(NotificationLevel::Error,
"[deps] Failed to write " + path);
return false;
}
out << j.dump(2);
logOut += "[deps] Wrote " + path + "\n";
notifications.notify(NotificationLevel::Success,
"[deps] Wrote " + path);
return true;
}
static bool writeDependenciesForSource(const std::string& source,
const std::string& workspaceRoot,
const std::vector<DependencySpec>& deps,
std::string& logOut) {
NotificationSystem& notifications) {
if (workspaceRoot.empty()) {
logOut += "[deps] No workspace root set.\n";
notifications.notify(NotificationLevel::Warning,
"[deps] No workspace root set.");
return false;
}
std::string base = sourceBase(source);
std::string path = resolveSourcePath(workspaceRoot, base);
auto filtered = depsForSource(deps, base);
if (base == "requirements.txt") return writeRequirementsFile(path, filtered, logOut);
if (base == "package.json") return writePackageJson(path, filtered, logOut);
if (base == "Cargo.toml") return writeCargoToml(path, filtered, logOut);
if (base == "go.mod") return writeGoMod(path, filtered, logOut);
if (base == "vcpkg.json") return writeVcpkgJson(path, filtered, logOut);
if (base == "requirements.txt") return writeRequirementsFile(path, filtered, notifications);
if (base == "package.json") return writePackageJson(path, filtered, notifications);
if (base == "Cargo.toml") return writeCargoToml(path, filtered, notifications);
if (base == "go.mod") return writeGoMod(path, filtered, notifications);
if (base == "vcpkg.json") return writeVcpkgJson(path, filtered, notifications);
logOut += "[deps] Writeback not implemented for " + base + "\n";
notifications.notify(NotificationLevel::Warning,
"[deps] Writeback not implemented for " + base);
return false;
}
static void refreshDependencies(DependencyPanelState& state,
const std::string& workspaceRoot,
std::string& logOut) {
NotificationSystem& notifications) {
state.deps.clear();
state.sources = discoverDependencyFiles(workspaceRoot);
state.selected = -1;
@@ -325,24 +338,25 @@ static void refreshDependencies(DependencyPanelState& state,
} else {
state.sourceSelected = -1;
}
logOut += "[deps] Loaded " + std::to_string(state.deps.size()) + " dependencies.\n";
notifications.notify(NotificationLevel::Info,
"[deps] Loaded " + std::to_string(state.deps.size()) + " dependencies.");
}
static void renderDependencyPanel(DependencyPanelState& state,
const std::string& workspaceRoot,
std::string& logOut) {
NotificationSystem& notifications) {
if (workspaceRoot.empty()) {
ImGui::TextDisabled("Open a workspace to manage dependencies.");
return;
}
if (state.lastWorkspaceRoot != workspaceRoot || state.deps.empty()) {
refreshDependencies(state, workspaceRoot, logOut);
refreshDependencies(state, workspaceRoot, notifications);
}
ImGui::InputText("Search", state.searchBuf, sizeof(state.searchBuf));
ImGui::SameLine();
if (ImGui::Button("Refresh")) {
refreshDependencies(state, workspaceRoot, logOut);
refreshDependencies(state, workspaceRoot, notifications);
}
ImGui::SameLine();
if (ImGui::Button("Add Package")) {
@@ -388,8 +402,9 @@ static void renderDependencyPanel(DependencyPanelState& state,
}
if (ImGui::Button("Search Registry")) {
state.lastLookup = PackageRegistry::query(state.addEcosystem, state.addBuf);
logOut += "[deps] Registry: " + state.lastLookup.name + " - " +
state.lastLookup.description + "\n";
notifications.notify(NotificationLevel::Info,
"[deps] Registry: " + state.lastLookup.name + " - " +
state.lastLookup.description);
}
if (!state.lastLookup.name.empty()) {
ImGui::TextWrapped("Found: %s", state.lastLookup.name.c_str());
@@ -397,7 +412,8 @@ static void renderDependencyPanel(DependencyPanelState& state,
ImGui::Separator();
if (ImGui::Button("Add")) {
if (state.addBuf[0] == '\0') {
logOut += "[deps] Add aborted: empty package name.\n";
notifications.notify(NotificationLevel::Warning,
"[deps] Add aborted: empty package name.");
} else {
DependencySpec dep;
dep.name = state.addBuf;
@@ -408,8 +424,8 @@ static void renderDependencyPanel(DependencyPanelState& state,
dep.source = "requirements.txt";
}
state.deps.push_back(dep);
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) {
refreshDependencies(state, workspaceRoot, logOut);
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) {
refreshDependencies(state, workspaceRoot, notifications);
state.needsIndex = true;
}
}
@@ -450,8 +466,8 @@ static void renderDependencyPanel(DependencyPanelState& state,
ImGui::InputText("Version", state.editVersionBuf, sizeof(state.editVersionBuf));
if (ImGui::Button("Apply Version")) {
dep.version = state.editVersionBuf;
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) {
refreshDependencies(state, workspaceRoot, logOut);
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) {
refreshDependencies(state, workspaceRoot, notifications);
state.needsIndex = true;
}
}
@@ -460,8 +476,8 @@ static void renderDependencyPanel(DependencyPanelState& state,
std::string src = dep.source;
state.deps.erase(state.deps.begin() + state.selected);
state.selected = -1;
if (writeDependenciesForSource(src, workspaceRoot, state.deps, logOut)) {
refreshDependencies(state, workspaceRoot, logOut);
if (writeDependenciesForSource(src, workspaceRoot, state.deps, notifications)) {
refreshDependencies(state, workspaceRoot, notifications);
state.needsIndex = true;
}
}
@@ -471,12 +487,13 @@ static void renderDependencyPanel(DependencyPanelState& state,
auto info = PackageRegistry::query(eco, dep.name);
if (!info.versions.empty()) {
dep.version = info.versions.back();
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) {
refreshDependencies(state, workspaceRoot, logOut);
if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, notifications)) {
refreshDependencies(state, workspaceRoot, notifications);
state.needsIndex = true;
}
} else {
logOut += "[deps] No version info for " + dep.name + "\n";
notifications.notify(NotificationLevel::Warning,
"[deps] No version info for " + dep.name);
}
}
}

View File

@@ -49,6 +49,7 @@
#include "HelpPanel.h"
#include "Telemetry.h"
#include "UpdateChecker.h"
#include "NotificationSystem.h"
#include "state/SearchState.h"
#include "state/AgentState.h"
#include "state/BuildState.h"
@@ -172,8 +173,7 @@ struct EditorState {
EmacsState emacsState;
UIFlags ui;
// Bottom panel + global log
std::string outputLog;
NotificationSystem notifications;
HelpPanelState helpPanel;
Telemetry telemetry;
char outlineFilter[128] = {};
@@ -326,6 +326,26 @@ struct EditorState {
return text.substr(start, pos - start);
}
void notify(NotificationLevel level, const std::string& message) {
notifications.notify(level, message);
}
void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) {
notifications.notify(level, message, target);
}
void navigateToTarget(const NotificationTarget& target) {
if (target.path.empty()) return;
std::string path = target.path;
if (buffers.hasBuffer(path)) switchToBuffer(path);
else doOpen(path);
if (active()) {
int line = std::max(0, target.line);
int col = std::max(0, target.col);
jumpTo(active(), line, col);
}
}
void jumpTo(BufferState* buf, int lineZero, int colZero) {
if (!buf) return;
int pos = byteOffsetForLineCol(buf->editBuf, lineZero, colZero);
@@ -760,7 +780,7 @@ struct EditorState {
}
void init() {
outputLog = "Whetstone Editor ready.\n";
notify(NotificationLevel::Info, "Whetstone Editor ready.");
workspaceRoot = std::filesystem::current_path().string();
search.projectSearch.setRoot(workspaceRoot);
fileTreeDirty = true;
@@ -824,7 +844,7 @@ struct EditorState {
std::string log;
bool ok = emacsState.emacs.startDaemonWithConfig(initPath, log);
if (!log.empty()) {
outputLog += "[emacs] " + log + "\n";
notify(NotificationLevel::Info, "[emacs] " + log);
}
if (!ok) {
EditorDiagnostic d;
@@ -936,7 +956,8 @@ struct EditorState {
build.lastBuildCommand = cmd.command;
int code = build.terminal.runAndCapture(workspaceRoot, cmd.command, build.lastBuildOutput);
build.buildErrors = BuildSystem::parseErrors(build.lastBuildOutput);
outputLog += "[build] " + cmd.label + " => exit " + std::to_string(code) + "\n";
notify(NotificationLevel::Info,
"[build] " + cmd.label + " => exit " + std::to_string(code));
return code;
}
@@ -953,13 +974,15 @@ struct EditorState {
library.libraryIndex.completionsByLibrary.clear();
library.libraryIndexRequests.clear();
if (!isStructured() || !activeAST()) {
outputLog += "[deps] Library index skipped: no structured AST.\n";
notify(NotificationLevel::Warning,
"[deps] Library index skipped: no structured AST.");
return;
}
if (!lsp || !lspTransport || !lspTransport->isOpen()) {
applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot);
rebuildExternalModulesFromIndex();
outputLog += "[deps] Library index uses cached symbols (LSP offline).\n";
notify(NotificationLevel::Warning,
"[deps] Library index uses cached symbols (LSP offline).");
return;
}
@@ -980,8 +1003,9 @@ struct EditorState {
}
applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot);
rebuildExternalModulesFromIndex();
outputLog += "[deps] Library index requests queued: " +
std::to_string(library.libraryIndexRequests.size()) + "\n";
notify(NotificationLevel::Info,
"[deps] Library index requests queued: " +
std::to_string(library.libraryIndexRequests.size()));
}
void processLibraryIndexResponses() {
@@ -1015,22 +1039,23 @@ struct EditorState {
if (updated) {
applyStubFallback(library.libraryIndex, library.dependencyPanel.deps, workspaceRoot);
rebuildExternalModulesFromIndex();
outputLog += "[deps] Library index updated.\n";
notify(NotificationLevel::Info, "[deps] Library index updated.");
}
}
void updateEmacsFunctionIndex() {
if (!active() || active()->language != "elisp") return;
std::string error;
auto packages = queryEmacsPackageList(emacsState.emacs, false, outputLog, error);
auto packages = queryEmacsPackageList(emacsState.emacs, false, notifications, error);
if (!error.empty()) return;
refreshEmacsFunctionIndex(emacsState.emacsFunctionIndex, emacsState.emacs, packages, outputLog);
refreshEmacsFunctionIndex(emacsState.emacsFunctionIndex, emacsState.emacs, packages, notifications);
addEmacsSymbolsToLibraryIndex(library.libraryIndex, emacsState.emacsFunctionIndex);
emacsState.emacsFunctionIndexDirty = false;
rebuildExternalModulesFromIndex();
outputLog += "[emacs] Indexed functions for " +
std::to_string(emacsState.emacsFunctionIndex.functionsByPackage.size()) +
" packages.\n";
notify(NotificationLevel::Info,
"[emacs] Indexed functions for " +
std::to_string(emacsState.emacsFunctionIndex.functionsByPackage.size()) +
" packages.");
}
void rebuildExternalModulesFromIndex() {
@@ -1544,7 +1569,8 @@ struct EditorState {
{"Open Project", {"*.whetstone"}, std::string()});
if (!path.empty()) {
if (!loadProject(path)) {
outputLog += "Failed to open project: " + path + "\n";
notify(NotificationLevel::Error,
"Failed to open project: " + path);
}
}
});
@@ -1555,7 +1581,8 @@ struct EditorState {
{"Save Project", {"*.whetstone"}, std::string()});
if (!path.empty()) {
if (!saveProject(path)) {
outputLog += "Failed to save project: " + path + "\n";
notify(NotificationLevel::Error,
"Failed to save project: " + path);
}
}
});
@@ -1669,54 +1696,58 @@ struct EditorState {
bool handleEmacsKeyChord(const std::string& chord) {
if (ui.layoutPreset != LayoutPreset::Emacs) return false;
return emacsHandleKeySequence(emacsState.emacsKeys, emacsState.emacs, chord, outputLog);
return emacsHandleKeySequence(emacsState.emacsKeys, emacsState.emacs, chord, notifications);
}
void refreshEmacsModeLine(double nowSeconds) {
if (ui.layoutPreset != LayoutPreset::Emacs) return;
updateEmacsModeLine(emacsState.emacsKeys, emacsState.emacs, nowSeconds, outputLog);
updateEmacsModeLine(emacsState.emacsKeys, emacsState.emacs, nowSeconds, notifications);
}
void pullFromEmacs() {
if (!active()) return;
if (active()->path.rfind("(untitled", 0) == 0) {
outputLog += "[emacs] Save file before syncing.\n";
notify(NotificationLevel::Warning, "[emacs] Save file before syncing.");
return;
}
std::string text = emacsState.emacs.getBufferText(active()->path);
if (!emacsState.emacs.getLastError().empty() && text == "error") {
outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n";
notify(NotificationLevel::Error,
"[emacs] " + emacsState.emacs.getLastError());
return;
}
active()->editBuf = text;
onTextChanged();
outputLog += "[emacs] Pulled buffer from Emacs.\n";
notify(NotificationLevel::Success, "[emacs] Pulled buffer from Emacs.");
}
void pushToEmacs() {
if (!active()) return;
if (active()->path.rfind("(untitled", 0) == 0) {
outputLog += "[emacs] Save file before syncing.\n";
notify(NotificationLevel::Warning, "[emacs] Save file before syncing.");
return;
}
if (!emacsState.emacs.setBufferText(active()->path, active()->editBuf)) {
outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n";
notify(NotificationLevel::Error,
"[emacs] " + emacsState.emacs.getLastError());
return;
}
outputLog += "[emacs] Pushed buffer to Emacs.\n";
notify(NotificationLevel::Success, "[emacs] Pushed buffer to Emacs.");
}
void openInEmacsFrame() {
if (!active()) return;
if (active()->path.rfind("(untitled", 0) == 0) {
outputLog += "[emacs] Save file before opening in Emacs.\n";
notify(NotificationLevel::Warning,
"[emacs] Save file before opening in Emacs.");
return;
}
if (!emacsState.emacs.openFileInEmacsFrame(active()->path)) {
outputLog += "[emacs] " + emacsState.emacs.getLastError() + "\n";
notify(NotificationLevel::Error,
"[emacs] " + emacsState.emacs.getLastError());
return;
}
outputLog += "[emacs] Opened in Emacs frame.\n";
notify(NotificationLevel::Success, "[emacs] Opened in Emacs frame.");
}
std::string buildRunCommand(const std::string& path,
@@ -1840,11 +1871,18 @@ struct EditorState {
}
active()->cursorLine = line;
active()->cursorCol = col;
outputLog += "Found \"" + std::string(search.findBuf) + "\" at line " +
std::to_string(line) + ", col " + std::to_string(col) + "\n";
NotificationTarget target;
target.path = active()->path;
target.line = std::max(0, line - 1);
target.col = std::max(0, col - 1);
notify(NotificationLevel::Info,
"Found \"" + std::string(search.findBuf) + "\" at line " +
std::to_string(line) + ", col " + std::to_string(col),
target);
} else {
search.lastFindPos = 0;
outputLog += "\"" + std::string(search.findBuf) + "\" not found.\n";
notify(NotificationLevel::Warning,
"\"" + std::string(search.findBuf) + "\" not found.");
}
}
@@ -1863,7 +1901,8 @@ struct EditorState {
active()->generatedHighlightsDirty = true;
active()->modified = true;
}
outputLog += "Replaced " + std::to_string(count) + " occurrence(s).\n";
notify(NotificationLevel::Info,
"Replaced " + std::to_string(count) + " occurrence(s).");
}
void doSave() {
@@ -1874,10 +1913,12 @@ struct EditorState {
out << active()->editBuf;
out.close();
active()->modified = false;
outputLog += "Saved: " + active()->path + "\n";
notify(NotificationLevel::Success,
"Saved: " + active()->path);
if (lsp) lsp->didSave(toFileUri(active()->path), active()->editBuf);
} else {
outputLog += "Error saving: " + active()->path + "\n";
notify(NotificationLevel::Error,
"Error saving: " + active()->path);
}
}
@@ -1912,9 +1953,9 @@ struct EditorState {
createBuffer(path, content, language, modeOverride);
welcome.addRecentFile(path, language, bufferModeToString(modeOverride));
saveRecentFiles();
outputLog += "Opened: " + path + "\n";
notify(NotificationLevel::Success, "Opened: " + path);
} else {
outputLog += "Error opening: " + path + "\n";
notify(NotificationLevel::Error, "Error opening: " + path);
}
}
@@ -1936,7 +1977,7 @@ struct EditorState {
buf->highlightsDirty = true;
buf->generatedHighlightsDirty = true;
buf->modified = false;
outputLog += "Reloaded: " + path + "\n";
notify(NotificationLevel::Info, "Reloaded: " + path);
}
void handleFileChanges() {
@@ -1945,7 +1986,8 @@ struct EditorState {
auto it = bufferStates.find(path);
if (it == bufferStates.end()) continue;
if (it->second->modified) {
outputLog += "File changed on disk (dirty): " + path + "\n";
notify(NotificationLevel::Warning,
"File changed on disk (dirty): " + path);
} else {
reloadBuffer(path);
}
@@ -1979,14 +2021,16 @@ struct EditorState {
if (!active()) return;
Module* ast = activeAST();
if (!ast) {
outputLog += "Project to " + targetLanguage + ": no AST available.\n";
notify(NotificationLevel::Error,
"Project to " + targetLanguage + ": no AST available.");
return;
}
CrossLanguageProjector projector;
auto projected = projector.project(ast, targetLanguage);
if (!projected) {
outputLog += "Project to " + targetLanguage + ": failed to project AST.\n";
notify(NotificationLevel::Error,
"Project to " + targetLanguage + ": failed to project AST.");
return;
}
@@ -2018,10 +2062,11 @@ struct EditorState {
active()->modified = false;
}
outputLog += "Projected to " + targetLanguage + " in " + projName +
" (annotations " + std::to_string(srcAnnoCount) + " -> " +
std::to_string(projAnnoCount) + ", types preserved: " +
(preserved ? "yes" : "no") + ").\n";
notify(NotificationLevel::Success,
"Projected to " + targetLanguage + " in " + projName +
" (annotations " + std::to_string(srcAnnoCount) + " -> " +
std::to_string(projAnnoCount) + ", types preserved: " +
(preserved ? "yes" : "no") + ").");
}
void refreshActiveTextFromAST() {

View File

@@ -2,6 +2,7 @@
#include "EmacsIntegration.h"
#include "EmacsPackageBrowser.h"
#include "LibraryIndexer.h"
#include "NotificationSystem.h"
#include "ast/Module.h"
#include "ast/ExternalModule.h"
#include "ast/TypeSignature.h"
@@ -35,11 +36,12 @@ static std::vector<std::string> parseLines(const std::string& text) {
static std::vector<std::string> queryEmacsFunctions(EmacsConnection& emacs,
const std::string& prefix,
std::string& logOut) {
NotificationSystem& notifications) {
std::string cmd = ElispCommandBuilder::aproposFunctions(prefix);
std::string resp = emacs.sendCommand(cmd);
if (resp.empty() && !emacs.getLastError().empty()) {
logOut += "[emacs] " + emacs.getLastError() + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + emacs.getLastError());
return {};
}
return parseLines(resp);
@@ -47,13 +49,14 @@ static std::vector<std::string> queryEmacsFunctions(EmacsConnection& emacs,
static EmacsFunctionDoc queryEmacsFunctionDoc(EmacsConnection& emacs,
const std::string& name,
std::string& logOut) {
NotificationSystem& notifications) {
EmacsFunctionDoc info;
info.name = name;
std::string cmd = ElispCommandBuilder::describeFunction(name);
std::string resp = emacs.sendCommand(cmd);
if (resp.empty() && !emacs.getLastError().empty()) {
logOut += "[emacs] " + emacs.getLastError() + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + emacs.getLastError());
return info;
}
auto pos = resp.find('\n');
@@ -81,20 +84,20 @@ static std::vector<std::string> buildPackageFunctionPrefixes(const std::string&
static void refreshEmacsFunctionIndex(EmacsFunctionIndex& index,
EmacsConnection& emacs,
const std::vector<EmacsPackageEntry>& packages,
std::string& logOut) {
NotificationSystem& notifications) {
index.functionsByPackage.clear();
for (const auto& pkg : packages) {
if (pkg.status != "loaded") continue;
std::vector<std::string> functions;
for (const auto& prefix : buildPackageFunctionPrefixes(pkg.name)) {
functions = queryEmacsFunctions(emacs, "^" + prefix, logOut);
functions = queryEmacsFunctions(emacs, "^" + prefix, notifications);
if (!functions.empty()) break;
}
if (functions.empty()) continue;
std::vector<EmacsFunctionDoc> docs;
docs.reserve(functions.size());
for (const auto& fn : functions) {
docs.push_back(queryEmacsFunctionDoc(emacs, fn, logOut));
docs.push_back(queryEmacsFunctionDoc(emacs, fn, notifications));
}
index.functionsByPackage[pkg.name] = std::move(docs);
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include "EmacsIntegration.h"
#include "NotificationSystem.h"
#include <string>
#include <algorithm>
#include <cctype>
@@ -29,7 +30,7 @@ static inline bool emacsIsPrefixKey(const std::string& chord) {
static inline bool emacsHandleKeySequence(EmacsKeybindingState& state,
EmacsConnection& emacs,
const std::string& chord,
std::string& logOut) {
NotificationSystem& notifications) {
if (chord.empty()) return false;
if (chord == "M-x") {
state.minibufferActive = true;
@@ -49,22 +50,25 @@ static inline bool emacsHandleKeySequence(EmacsKeybindingState& state,
std::string cmd = emacs.sendCommand(ElispCommandBuilder::keyBinding(sequence));
if (cmd.empty()) {
logOut += "[emacs] Unbound: " + sequence + "\n";
notifications.notify(NotificationLevel::Warning,
"[emacs] Unbound: " + sequence);
return true;
}
state.lastCommand = cmd;
std::string result = emacs.sendCommand(ElispCommandBuilder::callInteractive(cmd));
if (!emacs.getLastError().empty() && result == "error") {
logOut += "[emacs] " + emacs.getLastError() + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + emacs.getLastError());
} else {
logOut += "[emacs] " + cmd + "\n";
notifications.notify(NotificationLevel::Info,
"[emacs] " + cmd);
}
return true;
}
static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state,
EmacsConnection& emacs,
std::string& logOut) {
NotificationSystem& notifications) {
std::string cmd = emacsTrim(state.minibufferBuf);
if (cmd.empty()) {
state.minibufferActive = false;
@@ -72,9 +76,11 @@ static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state,
}
std::string result = emacs.sendCommand(ElispCommandBuilder::executeExtendedCommand(cmd));
if (!emacs.getLastError().empty() && result == "error") {
logOut += "[emacs] " + emacs.getLastError() + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + emacs.getLastError());
} else {
logOut += "[emacs] M-x " + cmd + "\n";
notifications.notify(NotificationLevel::Info,
"[emacs] M-x " + cmd);
}
state.minibufferActive = false;
state.minibufferBuf[0] = '\0';
@@ -84,11 +90,12 @@ static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state,
static inline void updateEmacsModeLine(EmacsKeybindingState& state,
EmacsConnection& emacs,
double nowSeconds,
std::string& logOut) {
NotificationSystem& notifications) {
if (nowSeconds - state.lastModeQuery < 1.0) return;
std::string mode = emacs.sendCommand(ElispCommandBuilder::modeLine());
if (!emacs.getLastError().empty() && mode == "error") {
logOut += "[emacs] " + emacs.getLastError() + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + emacs.getLastError());
return;
}
if (!mode.empty()) state.modeLine = mode;

View File

@@ -1,6 +1,7 @@
#pragma once
#include "imgui.h"
#include "EmacsIntegration.h"
#include "NotificationSystem.h"
#include "StringUtils.h"
#include <string>
#include <vector>
@@ -118,13 +119,14 @@ static std::vector<EmacsPackageEntry> mergePackageLists(const std::vector<EmacsP
static std::vector<EmacsPackageEntry> queryEmacsPackageList(EmacsConnection& emacs,
bool includeAvailable,
std::string& logOut,
NotificationSystem& notifications,
std::string& outError) {
outError.clear();
std::string loadedText = emacs.sendCommand(buildPackageListCommand("package-alist"));
if (loadedText.empty() && !emacs.getLastError().empty()) {
outError = emacs.getLastError();
logOut += "[emacs] " + outError + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + outError);
}
auto loaded = parsePackageListText(loadedText);
std::vector<EmacsPackageEntry> available;
@@ -132,7 +134,8 @@ static std::vector<EmacsPackageEntry> queryEmacsPackageList(EmacsConnection& ema
std::string availText = emacs.sendCommand(buildPackageListCommand("package-archive-contents"));
if (availText.empty() && !emacs.getLastError().empty()) {
outError = emacs.getLastError();
logOut += "[emacs] " + outError + "\n";
notifications.notify(NotificationLevel::Error,
"[emacs] " + outError);
}
available = parsePackageListText(availText);
}
@@ -141,23 +144,26 @@ static std::vector<EmacsPackageEntry> queryEmacsPackageList(EmacsConnection& ema
static void refreshEmacsPackages(EmacsPackageBrowserState& state,
EmacsConnection& emacs,
std::string& logOut) {
NotificationSystem& notifications) {
state.lastError.clear();
state.packages = queryEmacsPackageList(emacs, state.showAvailable, logOut, state.lastError);
state.packages = queryEmacsPackageList(emacs, state.showAvailable, notifications, state.lastError);
state.needsRefresh = false;
}
static bool loadEmacsPackage(EmacsConnection& emacs,
const std::string& packageName,
std::string& logOut) {
NotificationSystem& notifications) {
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";
notifications.notify(NotificationLevel::Error,
"[emacs] Failed to load " + packageName + ": " +
emacs.getLastError());
return false;
}
logOut += "[emacs] Loaded package: " + packageName + "\n";
notifications.notify(NotificationLevel::Success,
"[emacs] Loaded package: " + packageName);
return true;
}
@@ -170,7 +176,7 @@ static bool emacsPackageMatchesFilter(const EmacsPackageEntry& entry, const std:
static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state,
EmacsConnection& emacs,
std::string& logOut) {
NotificationSystem& notifications) {
bool updated = false;
if (ImGui::Button("Refresh")) {
state.needsRefresh = true;
@@ -183,7 +189,7 @@ static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state,
ImGui::InputText("Filter", state.filterBuf, sizeof(state.filterBuf));
if (state.needsRefresh) {
refreshEmacsPackages(state, emacs, logOut);
refreshEmacsPackages(state, emacs, notifications);
updated = true;
}
@@ -236,11 +242,13 @@ static bool renderEmacsPackageBrowser(EmacsPackageBrowserState& state,
}
if (ImGui::Button("Load")) {
if (loadTarget.empty()) {
logOut += "[emacs] No package selected.\n";
notifications.notify(NotificationLevel::Warning,
"[emacs] No package selected.");
} else {
bool ok = loadEmacsPackage(emacs, loadTarget, logOut);
bool ok = loadEmacsPackage(emacs, loadTarget, notifications);
if (!ok) {
logOut += "[emacs] Package not installed: " + loadTarget + "\n";
notifications.notify(NotificationLevel::Warning,
"[emacs] Package not installed: " + loadTarget);
}
state.needsRefresh = true;
updated = true;

View File

@@ -2,6 +2,7 @@
#include "imgui.h"
#include "LibraryIndexer.h"
#include "ast/ExternalModule.h"
#include "NotificationSystem.h"
#include <string>
#include <vector>
#include <algorithm>
@@ -58,7 +59,7 @@ static bool renderLibraryBrowser(LibraryBrowserState& state,
const LibraryIndexData& index,
std::string& outInsert,
std::string& outLibrary,
std::string& outputLog) {
NotificationSystem& notifications) {
outInsert.clear();
outLibrary.clear();
if (!ast) {
@@ -112,7 +113,8 @@ static bool renderLibraryBrowser(LibraryBrowserState& state,
if (ImGui::Button("Insert")) {
outInsert = usage;
outLibrary = state.selectedLibrary;
outputLog += "[libs] Inserted " + usage + "\n";
notifications.notify(NotificationLevel::Success,
"[libs] Inserted " + usage);
inserted = true;
}
ImGui::SameLine();

View File

@@ -0,0 +1,230 @@
#pragma once
#include "imgui.h"
#include <string>
#include <vector>
#include <functional>
enum class NotificationLevel {
Info,
Success,
Warning,
Error
};
struct NotificationTarget {
std::string path;
int line = -1; // zero-based
int col = -1; // zero-based
};
struct Notification {
int id = 0;
NotificationLevel level = NotificationLevel::Info;
std::string message;
double timestamp = 0.0;
float durationSeconds = 4.0f;
bool dismissed = false;
bool read = false;
bool hasTarget = false;
NotificationTarget target;
};
class NotificationSystem {
public:
float toastDurationSeconds = 4.0f;
bool showHistory = false;
void notify(NotificationLevel level, const std::string& message) {
notifyAt(level, message, ImGui::GetTime(), nullptr);
}
void notify(NotificationLevel level, const std::string& message, const NotificationTarget& target) {
notifyAt(level, message, ImGui::GetTime(), &target);
}
void notifyAt(NotificationLevel level, const std::string& message, double nowSeconds) {
notifyAt(level, message, nowSeconds, nullptr);
}
void notifyAt(NotificationLevel level,
const std::string& message,
double nowSeconds,
const NotificationTarget* target) {
Notification n;
n.id = nextId++;
n.level = level;
n.message = message;
n.timestamp = nowSeconds;
n.durationSeconds = toastDurationSeconds;
if (target) {
n.hasTarget = true;
n.target = *target;
}
history.push_back(std::move(n));
}
void dismiss(int id) {
for (auto& n : history) {
if (n.id == id) {
n.dismissed = true;
n.read = true;
return;
}
}
}
void markAllRead() {
for (auto& n : history) {
n.read = true;
}
}
int unreadCount() const {
int count = 0;
for (const auto& n : history) {
if (!n.read) ++count;
}
return count;
}
const std::vector<Notification>& getHistory() const {
return history;
}
void renderToasts(const std::function<void(const Notification&)>& onNavigate = {}) {
const double now = ImGui::GetTime();
const ImGuiViewport* viewport = ImGui::GetMainViewport();
const ImVec2 base(viewport->WorkPos.x + viewport->WorkSize.x,
viewport->WorkPos.y + viewport->WorkSize.y);
const float padding = 12.0f;
const float toastWidth = 360.0f;
float y = base.y - padding;
for (int i = (int)history.size() - 1; i >= 0; --i) {
auto& n = history[i];
if (n.dismissed) continue;
if ((now - n.timestamp) > n.durationSeconds) continue;
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings;
ImGui::SetNextWindowPos(ImVec2(base.x - padding, y), ImGuiCond_Always, ImVec2(1.0f, 1.0f));
ImGui::SetNextWindowSize(ImVec2(toastWidth, 0.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12, 10));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.92f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.22f, 0.22f, 0.22f, 0.9f));
ImGui::Begin(("##toast_" + std::to_string(n.id)).c_str(), nullptr, flags);
ImVec4 color = levelColor(n.level);
ImGui::TextColored(color, "%s", levelLabel(n.level));
ImGui::SameLine(0.0f, 8.0f);
ImGui::TextWrapped("%s", n.message.c_str());
if (n.hasTarget) {
std::string loc = n.target.path;
if (n.target.line >= 0) {
loc += ":" + std::to_string(n.target.line + 1);
if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1);
}
ImGui::TextDisabled("%s", loc.c_str());
}
bool clicked = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) &&
ImGui::IsMouseClicked(0);
ImVec2 size = ImGui::GetWindowSize();
ImGui::End();
ImGui::PopStyleColor(2);
ImGui::PopStyleVar();
if (clicked) {
n.read = true;
n.dismissed = true;
if (n.hasTarget && onNavigate) {
onNavigate(n);
}
}
y -= size.y + 8.0f;
}
}
void renderHistoryWindow(const std::function<void(const Notification&)>& onNavigate = {}) {
if (!showHistory) {
historyWasOpen = false;
return;
}
if (!historyWasOpen) {
markAllRead();
historyWasOpen = true;
}
ImGui::SetNextWindowSize(ImVec2(520, 360), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Notifications", &showHistory)) {
ImGui::End();
return;
}
if (history.empty()) {
ImGui::TextDisabled("(no notifications yet)");
ImGui::End();
return;
}
ImGui::BeginChild("##notificationHistory", ImVec2(0, 0), false);
for (const auto& n : history) {
ImGui::PushID(n.id);
ImVec4 color = levelColor(n.level);
ImGui::TextColored(color, "%s", levelLabel(n.level));
ImGui::SameLine();
if (ImGui::Selectable(n.message.c_str(), false)) {
if (n.hasTarget && onNavigate) {
onNavigate(n);
}
}
if (n.hasTarget) {
std::string loc = n.target.path;
if (n.target.line >= 0) {
loc += ":" + std::to_string(n.target.line + 1);
if (n.target.col >= 0) loc += ":" + std::to_string(n.target.col + 1);
}
ImGui::TextDisabled("%s", loc.c_str());
}
ImGui::Separator();
ImGui::PopID();
}
ImGui::EndChild();
ImGui::End();
}
private:
int nextId = 1;
bool historyWasOpen = false;
std::vector<Notification> history;
static const char* levelLabel(NotificationLevel level) {
switch (level) {
case NotificationLevel::Success: return "Success";
case NotificationLevel::Warning: return "Warning";
case NotificationLevel::Error: return "Error";
case NotificationLevel::Info:
default:
return "Info";
}
}
static ImVec4 levelColor(NotificationLevel level) {
switch (level) {
case NotificationLevel::Success: return ImVec4(0.25f, 0.78f, 0.35f, 1.0f);
case NotificationLevel::Warning: return ImVec4(0.95f, 0.72f, 0.28f, 1.0f);
case NotificationLevel::Error: return ImVec4(0.90f, 0.35f, 0.35f, 1.0f);
case NotificationLevel::Info:
default:
return ImVec4(0.40f, 0.70f, 0.95f, 1.0f);
}
}
};

View File

@@ -298,6 +298,12 @@ int main(int, char**) {
renderBottomPanel(state);
renderMemoryStrategiesPanel(state);
renderStatusBar(state);
state.notifications.renderHistoryWindow([&](const Notification& note) {
if (note.hasTarget) state.navigateToTarget(note.target);
});
state.notifications.renderToasts([&](const Notification& note) {
if (note.hasTarget) state.navigateToTarget(note.target);
});
// Check for exit request from menu
if (state.exitRequested) done = true;

View File

@@ -13,8 +13,30 @@ static void renderBottomPanel(EditorState& state) {
if (ImGui::BeginTabItem("Output")) {
ImGui::PushFont(state.monoFont);
ImGui::BeginChild("##outputScroll", ImVec2(0, 0), false);
ImGui::TextUnformatted(state.outputLog.c_str());
// Auto-scroll to bottom
for (const auto& note : state.notifications.getHistory()) {
const char* label = "Info";
ImVec4 color = ImVec4(0.40f, 0.70f, 0.95f, 1.0f);
switch (note.level) {
case NotificationLevel::Success:
label = "Success";
color = ImVec4(0.25f, 0.78f, 0.35f, 1.0f);
break;
case NotificationLevel::Warning:
label = "Warning";
color = ImVec4(0.95f, 0.72f, 0.28f, 1.0f);
break;
case NotificationLevel::Error:
label = "Error";
color = ImVec4(0.90f, 0.35f, 0.35f, 1.0f);
break;
case NotificationLevel::Info:
default:
break;
}
ImGui::TextColored(color, "[%s]", label);
ImGui::SameLine();
ImGui::TextWrapped("%s", note.message.c_str());
}
if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 20)
ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
@@ -86,7 +108,7 @@ static void renderBottomPanel(EditorState& state) {
ImGui::PushFont(state.monoFont);
renderAgentMarketplace(state.agent.marketplace,
state.agent.registry,
state.outputLog,
state.notifications,
state.workspaceRoot);
ImGui::PopFont();
ImGui::EndTabItem();
@@ -261,10 +283,10 @@ static void renderBottomPanel(EditorState& state) {
}
if (!warning.empty()) {
summary += " — warning: " + warning;
state.outputLog += warning + "\n";
state.notify(NotificationLevel::Warning, warning);
}
state.optimizeFoldSummary = summary;
state.outputLog += summary + "\n";
state.notify(NotificationLevel::Info, summary);
if (nodes > 0) state.applyOrchestratorToActive();
std::string afterText = state.active()->editBuf;
state.openDiff(beforeText, afterText, false, 1, {tid});
@@ -308,7 +330,7 @@ static void renderBottomPanel(EditorState& state) {
summary += "no changes";
}
state.optimizeDeadCodeSummary = summary;
state.outputLog += summary + "\n";
state.notify(NotificationLevel::Info, summary);
if (nodes > 0) state.applyOrchestratorToActive();
std::string afterText = state.active()->editBuf;
state.openDiff(beforeText, afterText, false, 2, {tid});
@@ -354,10 +376,10 @@ static void renderBottomPanel(EditorState& state) {
}
if (!warning.empty()) {
summary += " — warning: " + warning;
state.outputLog += warning + "\n";
state.notify(NotificationLevel::Warning, warning);
}
state.optimizeApplySummary = summary;
state.outputLog += summary + "\n";
state.notify(NotificationLevel::Info, summary);
if (totalNodes > 0) state.applyOrchestratorToActive();
std::string afterText = state.active()->editBuf;
state.openDiff(beforeText, afterText, false, 3, {tidFold, tidDce});
@@ -460,10 +482,11 @@ static void renderBottomPanel(EditorState& state) {
batch.setRoot(ast);
auto res = batch.applySequence(state.diff.batchMutations);
if (!res.success) {
state.outputLog += res.error + "\n";
state.notify(NotificationLevel::Error, res.error);
} else {
state.outputLog += "Refactor applied (" +
std::to_string(res.appliedCount) + " mutations).\n";
state.notify(NotificationLevel::Success,
"Refactor applied (" +
std::to_string(res.appliedCount) + " mutations).");
state.applyOrchestratorToActive();
}
} else {

View File

@@ -155,8 +155,9 @@ static void renderRefactorPopup(EditorState& state) {
if (!res.success) {
state.refactorError = res.error;
} else {
state.outputLog += "Refactor applied (" +
std::to_string(res.appliedCount) + " mutations).\n";
state.notify(NotificationLevel::Success,
"Refactor applied (" +
std::to_string(res.appliedCount) + " mutations).");
state.applyOrchestratorToActive();
state.showRefactorPopup = false;
}

View File

@@ -452,7 +452,7 @@ static void renderEditorPanel(EditorState& state) {
}
state.mutator.setRoot(ast);
auto res2 = state.mutator.insertNode(target->id, "annotations", anno);
if (!res2.error.empty()) state.outputLog += res2.error + "\n";
if (!res2.error.empty()) state.notify(NotificationLevel::Error, res2.error);
state.applyOrchestratorToActive();
}
}
@@ -555,7 +555,7 @@ static void renderEditorPanel(EditorState& state) {
target->spanEndLine, target->spanEndCol);
state.mutator.setRoot(ast);
auto res = state.mutator.insertNode(target->id, "annotations", anno);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -566,7 +566,7 @@ static void renderEditorPanel(EditorState& state) {
target->spanEndLine, target->spanEndCol);
state.mutator.setRoot(ast);
auto res = state.mutator.insertNode(target->id, "annotations", anno);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -577,7 +577,7 @@ static void renderEditorPanel(EditorState& state) {
target->spanEndLine, target->spanEndCol);
state.mutator.setRoot(ast);
auto res = state.mutator.insertNode(target->id, "annotations", anno);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -588,7 +588,7 @@ static void renderEditorPanel(EditorState& state) {
target->spanEndLine, target->spanEndCol);
state.mutator.setRoot(ast);
auto res = state.mutator.insertNode(target->id, "annotations", anno);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -599,7 +599,7 @@ static void renderEditorPanel(EditorState& state) {
target->spanEndLine, target->spanEndCol);
state.mutator.setRoot(ast);
auto res = state.mutator.insertNode(target->id, "annotations", anno);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -615,35 +615,35 @@ static void renderEditorPanel(EditorState& state) {
if (ImGui::MenuItem("Tracing")) {
state.mutator.setRoot(ast);
auto res = state.mutator.setProperty(anno->id, "strategy", "Tracing");
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
} else if (anno->conceptType == "OwnerAnnotation") {
if (ImGui::MenuItem("Single")) {
state.mutator.setRoot(ast);
auto res = state.mutator.setProperty(anno->id, "strategy", "Single");
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
} else if (anno->conceptType == "DeallocateAnnotation") {
if (ImGui::MenuItem("Explicit")) {
state.mutator.setRoot(ast);
auto res = state.mutator.setProperty(anno->id, "strategy", "Explicit");
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
} else if (anno->conceptType == "LifetimeAnnotation") {
if (ImGui::MenuItem("RAII")) {
state.mutator.setRoot(ast);
auto res = state.mutator.setProperty(anno->id, "strategy", "RAII");
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
} else if (anno->conceptType == "AllocateAnnotation") {
if (ImGui::MenuItem("Static")) {
state.mutator.setRoot(ast);
auto res = state.mutator.setProperty(anno->id, "strategy", "Static");
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -659,7 +659,7 @@ static void renderEditorPanel(EditorState& state) {
if (ImGui::MenuItem(label.c_str())) {
state.mutator.setRoot(ast);
auto res = state.mutator.deleteNode(anno->id);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}
@@ -682,7 +682,7 @@ static void renderEditorPanel(EditorState& state) {
if (ImGui::MenuItem("Remove child annotation")) {
state.mutator.setRoot(ast);
auto res = state.mutator.deleteNode(lineConflict.childAnnoId);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
if (ImGui::MenuItem("Match parent strategy")) {
@@ -690,7 +690,7 @@ static void renderEditorPanel(EditorState& state) {
auto res = state.mutator.setProperty(lineConflict.childAnnoId,
"strategy",
lineConflict.parentStrategy);
if (!res.error.empty()) state.outputLog += res.error + "\n";
if (!res.error.empty()) state.notify(NotificationLevel::Error, res.error);
if (res.error.empty()) mutated = true;
}
}

View File

@@ -24,7 +24,8 @@ static void renderMenuBar(EditorState& state) {
auto path = FileDialog::openFile({"Open Project", {"*.whetstone"}, state.lastDialogPath});
if (!path.empty()) {
if (!state.loadProject(path)) {
state.outputLog += "Failed to open project: " + path + "\n";
state.notify(NotificationLevel::Error,
"Failed to open project: " + path);
} else {
state.lastDialogPath = path;
}
@@ -59,7 +60,8 @@ static void renderMenuBar(EditorState& state) {
auto path = FileDialog::saveFile({"Save Project", {"*.whetstone"}, state.lastDialogPath});
if (!path.empty()) {
if (!state.saveProject(path)) {
state.outputLog += "Failed to save project: " + path + "\n";
state.notify(NotificationLevel::Error,
"Failed to save project: " + path);
} else {
state.lastDialogPath = path;
}

View File

@@ -80,9 +80,11 @@ static void renderSettingsPanel(EditorState& state) {
UpdateChecker checker;
auto info = checker.check(state.settings.getUpdateUrl());
if (info.available) {
state.outputLog += "[update] Available: " + info.version + " (" + info.url + ")\n";
state.notify(NotificationLevel::Info,
"[update] Available: " + info.version + " (" + info.url + ")");
} else {
state.outputLog += "[update] No update info (offline/stub).\n";
state.notify(NotificationLevel::Info,
"[update] No update info (offline/stub).");
}
}

View File

@@ -80,7 +80,7 @@ static void renderDependenciesPanel(EditorState& state) {
if (!state.library.showDependencyPanel) return;
ImGui::Begin("Dependencies", &state.library.showDependencyPanel);
ImGui::PushFont(state.uiFont);
renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, state.outputLog);
renderDependencyPanel(state.library.dependencyPanel, state.workspaceRoot, state.notifications);
ImGui::PopFont();
ImGui::End();
if (state.library.dependencyPanel.needsIndex) {
@@ -100,7 +100,7 @@ static void renderLibrariesPanel(EditorState& state) {
state.library.libraryIndex,
insertText,
insertLibrary,
state.outputLog)) {
state.notifications)) {
state.ensureImportForSymbol(insertLibrary, insertText);
state.insertTextAtCursor(insertText);
}
@@ -123,7 +123,7 @@ static void renderCompositionPanel(EditorState& state) {
auto funcs = state.library.primitives.getAvailableFunctions(nodeId);
primitives.insert(primitives.end(), funcs.begin(), funcs.end());
std::string code;
if (renderCompositionPanel(state.library.compositionPanel, primitives, code, state.outputLog)) {
if (renderCompositionPanel(state.library.compositionPanel, primitives, code, state.notifications)) {
state.insertTextAtCursor(code);
}
ImGui::PopFont();
@@ -134,7 +134,9 @@ static void renderEmacsPackagesPanel(EditorState& state) {
if (!state.emacsState.showEmacsPackagesPanel) return;
ImGui::Begin("Emacs Packages", &state.emacsState.showEmacsPackagesPanel);
ImGui::PushFont(state.uiFont);
if (renderEmacsPackageBrowser(state.emacsState.emacsPackages, state.emacsState.emacs, state.outputLog)) {
if (renderEmacsPackageBrowser(state.emacsState.emacsPackages,
state.emacsState.emacs,
state.notifications)) {
state.emacsState.emacsFunctionIndexDirty = true;
}
ImGui::PopFont();
@@ -188,7 +190,9 @@ static void renderMinibuffer(EditorState& state) {
state.emacsState.emacsKeys.minibufferBuf,
sizeof(state.emacsState.emacsKeys.minibufferBuf),
ImGuiInputTextFlags_EnterReturnsTrue)) {
emacsExecuteMinibuffer(state.emacsState.emacsKeys, state.emacsState.emacs, state.outputLog);
emacsExecuteMinibuffer(state.emacsState.emacsKeys,
state.emacsState.emacs,
state.notifications);
}
ImGui::PopFont();
ImGui::End();
@@ -237,7 +241,8 @@ static void renderMemoryStrategiesPanel(EditorState& state) {
if (ImGui::Button("Export JSON")) {
auto j = buildAnnotationSummaryJson(entries);
state.outputLog += "Annotation summary:\\n" + j.dump(2) + "\\n";
state.notify(NotificationLevel::Info,
"Annotation summary:\\n" + j.dump(2));
}
}
ImGui::PopFont();

View File

@@ -68,6 +68,16 @@ static void renderStatusBar(EditorState& state) {
ImGui::SameLine(0, 30);
ImGui::Text("UTF-8");
ImGui::SameLine(0, 30);
int unread = state.notifications.unreadCount();
std::string notifLabel = "Notifications";
if (unread > 0) {
notifLabel += " (" + std::to_string(unread) + ")";
}
if (ImGui::Button(notifLabel.c_str())) {
state.notifications.showHistory = !state.notifications.showHistory;
}
ImGui::PopFont();
ImGui::End();
ImGui::PopStyleVar();

View File

@@ -0,0 +1,41 @@
// Step 169: Notification system integration wiring checks.
#include <cassert>
#include <filesystem>
#include <fstream>
#include <string>
namespace fs = std::filesystem;
static std::string readFile(const std::string& path) {
std::ifstream f(path);
if (!f.is_open()) return {};
return std::string((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
static void assertContains(const std::string& text, const std::string& needle) {
assert(text.find(needle) != std::string::npos);
}
int main() {
assert(fs::exists("src/NotificationSystem.h"));
const std::string editorState = readFile("src/EditorState.h");
assertContains(editorState, "NotificationSystem");
assertContains(editorState, "notifications");
const std::string statusBar = readFile("src/panels/StatusBarPanel.h");
assertContains(statusBar, "Notifications");
assertContains(statusBar, "state.notifications");
const std::string mainCpp = readFile("src/main.cpp");
assertContains(mainCpp, "renderHistoryWindow");
assertContains(mainCpp, "renderToasts");
const std::string bottomPanel = readFile("src/panels/BottomPanel.h");
assertContains(bottomPanel, "state.notifications");
printf("step169_integration_test: all assertions passed\n");
return 0;
}

View File

@@ -0,0 +1,48 @@
// Step 169: Notification system unit checks.
#include <cassert>
#include <string>
#include "NotificationSystem.h"
static const Notification* findById(const std::vector<Notification>& list, int id) {
for (const auto& n : list) {
if (n.id == id) return &n;
}
return nullptr;
}
int main() {
NotificationSystem notifications;
notifications.toastDurationSeconds = 5.0f;
notifications.notifyAt(NotificationLevel::Info, "hello", 1.0);
assert(notifications.getHistory().size() == 1);
assert(notifications.unreadCount() == 1);
NotificationTarget target;
target.path = "C:\\temp\\example.cpp";
target.line = 2;
target.col = 5;
notifications.notifyAt(NotificationLevel::Error, "boom", 2.0, &target);
assert(notifications.getHistory().size() == 2);
const Notification& last = notifications.getHistory().back();
assert(last.hasTarget);
assert(last.target.path == target.path);
assert(last.target.line == target.line);
assert(last.target.col == target.col);
int id = last.id;
notifications.dismiss(id);
const Notification* found = findById(notifications.getHistory(), id);
assert(found);
assert(found->dismissed);
assert(found->read);
notifications.markAllRead();
assert(notifications.unreadCount() == 0);
printf("step169_test: all assertions passed\n");
return 0;
}

View File

@@ -63,7 +63,7 @@ first — all UX work becomes easier when panels are modular.
All files under 600 lines after split.
*Modifies:* 5+ oversized headers
- [ ] **Step 169: Notification / toast system**
- [x] **Step 169: Notification / toast system**
Replace ad-hoc `outputLog +=` with a structured notification system.
`NotificationSystem.h`:
- Notification types: Success, Warning, Error, Info