From d5d2174c7b80932defc2ba5cc35a5b61866577aa Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 15:04:25 -0700 Subject: [PATCH] Step 122: add integrated terminal panel --- editor/CMakeLists.txt | 4 + editor/src/EditorState.h | 6 + editor/src/TerminalPanel.h | 210 ++++++++++++++++++++++++++++++++++ editor/src/main.cpp | 12 +- editor/tests/step122_test.cpp | 32 ++++++ sprint4_plan.md | 2 +- 6 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 editor/src/TerminalPanel.h create mode 100644 editor/tests/step122_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 459845c..2fafc66 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -689,6 +689,10 @@ add_executable(step121_test tests/step121_test.cpp) target_include_directories(step121_test PRIVATE src) target_link_libraries(step121_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step122_test tests/step122_test.cpp) +target_include_directories(step122_test PRIVATE src) +target_link_libraries(step122_test PRIVATE 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 b207393..9fb4236 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -40,6 +40,7 @@ #include "ProjectManager.h" #include "SessionManager.h" #include "ZoomUtils.h" +#include "TerminalPanel.h" #include "IncrementalOptimizer.h" #include "ast/Serialization.h" #include "ast/Generator.h" @@ -143,6 +144,8 @@ struct EditorState { // Bottom panel int bottomTab = 0; // 0=Output, 1=AST, 2=Highlighted std::string outputLog; + bool showTerminalPanel = false; + TerminalPanel terminal; // Custom editor widget state bool showWhitespace = false; @@ -834,6 +837,9 @@ struct EditorState { [this]() { showAnnotations = !showAnnotations; }); registerCommand("view.outline", "View: Toggle Outline", "", [this]() { showOutline = !showOutline; }); + registerCommand("view.toggleTerminal", "View: Toggle Terminal", + keys.getBinding("view.toggleTerminal").toString(), + [this]() { showTerminalPanel = !showTerminalPanel; }); registerCommand("view.lsp", "View: LSP Servers...", "", [this]() { showLspSettings = !showLspSettings; }); registerCommand("mode.toggle", "Mode: Toggle Text/Structured", "", diff --git a/editor/src/TerminalPanel.h b/editor/src/TerminalPanel.h new file mode 100644 index 0000000..208f5d6 --- /dev/null +++ b/editor/src/TerminalPanel.h @@ -0,0 +1,210 @@ +#pragma once +#include "imgui.h" +#include +#include +#include +#include + +class TerminalPanel { +public: + static std::string buildCommandLine(const std::string& cwd, const std::string& command) { + std::string workingDir = cwd.empty() ? "." : cwd; +#ifdef _WIN32 + return "cmd /C \"cd /d \\\"" + escapeForCmd(workingDir) + "\\\" && " + + command + " 2>&1\""; +#else + return "sh -c 'cd \"" + escapeForSh(workingDir) + "\" && " + + command + " 2>&1'"; +#endif + } + + void render(const std::string& cwd, ImFont* monoFont) { + ImGui::PushFont(monoFont ? monoFont : ImGui::GetFont()); + + ImGui::PushItemWidth(-80.0f); + bool runNow = inputText("##terminalCmd", commandBuf_, + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopItemWidth(); + ImGui::SameLine(); + if (ImGui::Button("Run") || runNow) { + runCommand(cwd); + } + ImGui::SameLine(); + if (ImGui::Button("Clear")) { + output_.clear(); + } + + ImGui::Separator(); + + ImGui::BeginChild("##terminalOutput", ImVec2(0, 0), false); + if (output_.empty()) { + ImGui::TextDisabled("(no output)"); + } else { + renderAnsiText(output_); + if (autoScroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 20.0f) { + ImGui::SetScrollHereY(1.0f); + } + } + ImGui::EndChild(); + + ImGui::PopFont(); + } + + void append(const std::string& text) { + output_ += text; + } + + const std::string& getOutput() const { + return output_; + } + +private: + std::string commandBuf_; + std::string output_; + bool autoScroll_ = true; + + struct InputTextCallbackData { + std::string* str; + }; + + static int inputTextCallback(ImGuiInputTextCallbackData* data) { + auto* userData = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + userData->str->resize(data->BufTextLen); + data->Buf = userData->str->data(); + } + return 0; + } + + static bool inputText(const char* label, std::string& str, ImGuiInputTextFlags flags) { + flags |= ImGuiInputTextFlags_CallbackResize; + InputTextCallbackData cbData{&str}; + if (str.capacity() < str.size() + 64) str.reserve(str.size() + 256); + return ImGui::InputText(label, str.data(), str.capacity() + 1, + flags, inputTextCallback, &cbData); + } + + static std::string escapeForCmd(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '"') out += "\\\""; + else out += c; + } + return out; + } + + static std::string escapeForSh(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '\'') out += "'\\''"; + else out += c; + } + return out; + } + + void runCommand(const std::string& cwd) { + if (commandBuf_.empty()) return; + + std::string fullCmd = buildCommandLine(cwd, commandBuf_); +#ifdef _WIN32 + FILE* pipe = _popen(fullCmd.c_str(), "r"); +#else + FILE* pipe = popen(fullCmd.c_str(), "r"); +#endif + if (!pipe) { + output_ += "[terminal] Failed to launch command.\n"; + return; + } + + output_ += "> " + commandBuf_ + "\n"; + char buffer[4096]; + while (!feof(pipe)) { + if (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output_ += buffer; + } + } +#ifdef _WIN32 + _pclose(pipe); +#else + pclose(pipe); +#endif + } + + static ImVec4 ansiColor(int code, const ImVec4& base) { + switch (code) { + case 30: return ImVec4(0.0f, 0.0f, 0.0f, 1.0f); + case 31: return ImVec4(0.85f, 0.2f, 0.2f, 1.0f); + case 32: return ImVec4(0.2f, 0.75f, 0.3f, 1.0f); + case 33: return ImVec4(0.85f, 0.65f, 0.2f, 1.0f); + case 34: return ImVec4(0.35f, 0.55f, 0.95f, 1.0f); + case 35: return ImVec4(0.8f, 0.35f, 0.8f, 1.0f); + case 36: return ImVec4(0.2f, 0.75f, 0.75f, 1.0f); + case 37: return ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + case 90: return ImVec4(0.45f, 0.45f, 0.45f, 1.0f); + case 91: return ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + case 92: return ImVec4(0.4f, 1.0f, 0.4f, 1.0f); + case 93: return ImVec4(1.0f, 0.9f, 0.4f, 1.0f); + case 94: return ImVec4(0.5f, 0.7f, 1.0f, 1.0f); + case 95: return ImVec4(1.0f, 0.6f, 1.0f, 1.0f); + case 96: return ImVec4(0.5f, 1.0f, 1.0f, 1.0f); + case 97: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + case 39: return base; + default: return base; + } + } + + static void renderAnsiText(const std::string& text) { + ImVec4 baseColor = ImGui::GetStyleColorVec4(ImGuiCol_Text); + ImVec4 currentColor = baseColor; + std::string buffer; + bool lineStart = true; + + auto emit = [&](const std::string& chunk) { + if (chunk.empty()) return; + if (!lineStart) ImGui::SameLine(0.0f, 0.0f); + ImGui::TextColored(currentColor, "%s", chunk.c_str()); + lineStart = false; + }; + + for (size_t i = 0; i < text.size(); ) { + if (text[i] == '\x1b' && i + 1 < text.size() && text[i + 1] == '[') { + emit(buffer); + buffer.clear(); + i += 2; + std::string codeStr; + while (i < text.size() && text[i] != 'm') { + codeStr += text[i++]; + } + if (i < text.size() && text[i] == 'm') i++; + if (codeStr.empty()) { + currentColor = baseColor; + continue; + } + std::stringstream ss(codeStr); + std::string part; + while (std::getline(ss, part, ';')) { + int code = 0; + try { code = std::stoi(part); } catch (...) { code = 0; } + if (code == 0) { + currentColor = baseColor; + } else { + currentColor = ansiColor(code, currentColor); + } + } + continue; + } + if (text[i] == '\n') { + emit(buffer); + buffer.clear(); + ImGui::NewLine(); + lineStart = true; + ++i; + continue; + } + buffer.push_back(text[i++]); + } + emit(buffer); + } +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 53ead60..e7e1b58 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -164,6 +164,9 @@ int main(int, char**) { state.goToLineBuf[0] = '\0'; state.goToLineError = false; } + else if (action == "view.toggleTerminal") { + state.showTerminalPanel = !state.showTerminalPanel; + } else if (action == "file.save") state.doSave(); else if (action == "file.new") { std::string lang = state.active() ? state.active()->language : "python"; @@ -293,6 +296,8 @@ int main(int, char**) { ImGui::MenuItem("Show Minimap", nullptr, &state.showMinimap); ImGui::MenuItem("Show Annotations", nullptr, &state.showAnnotations); ImGui::MenuItem("Show Outline", nullptr, &state.showOutline); + ImGui::MenuItem("Terminal", state.keys.getBinding("view.toggleTerminal").toString().c_str(), + &state.showTerminalPanel); ImGui::MenuItem("Settings", nullptr, &state.showSettingsPanel); ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings); if (state.active()) { @@ -1588,7 +1593,7 @@ int main(int, char**) { ImGui::End(); // --------------------------------------------------------------- - // Bottom panel — Output / AST / Highlighted Preview + // Bottom panel — Output / AST / Highlighted Preview / Terminal // --------------------------------------------------------------- ImGui::Begin("Panel"); @@ -1606,6 +1611,11 @@ int main(int, char**) { ImGui::EndTabItem(); } + if (state.showTerminalPanel && ImGui::BeginTabItem("Terminal")) { + state.terminal.render(state.workspaceRoot, monoFont); + ImGui::EndTabItem(); + } + // Problems (LSP diagnostics) if (ImGui::BeginTabItem("Problems")) { ImGui::PushFont(monoFont); diff --git a/editor/tests/step122_test.cpp b/editor/tests/step122_test.cpp new file mode 100644 index 0000000..2182b97 --- /dev/null +++ b/editor/tests/step122_test.cpp @@ -0,0 +1,32 @@ +// Step 122 TDD Test: Terminal panel command building + output append +#include "TerminalPanel.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; + + // Test 1: command line includes cwd + command + stderr redirect + std::string cmd = TerminalPanel::buildCommandLine("C:\\work", "echo hi"); + expect(cmd.find("echo hi") != std::string::npos, "command contains payload", passed, failed); + expect(cmd.find("2>&1") != std::string::npos, "command captures stderr", passed, failed); + + // Test 2: output append works + TerminalPanel panel; + panel.append("hello"); + panel.append(" world"); + expect(panel.getOutput() == "hello world", "append output", passed, failed); + + std::cout << "\n=== Step 122 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 91325f5..028ddf9 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -406,7 +406,7 @@ AST extensions for Sprint 5. External tool integration and agent connectivity. -- [ ] **Step 122: Integrated terminal** +- [x] **Step 122: Integrated terminal** New "Terminal" tab in the bottom panel. Subprocess output capture (not a full terminal emulator — captures stdout/stderr from spawned processes). Run shell commands, see output. Ctrl+` to toggle. Working directory =