Step 122: add integrated terminal panel
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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", "",
|
||||
|
||||
210
editor/src/TerminalPanel.h
Normal file
210
editor/src/TerminalPanel.h
Normal file
@@ -0,0 +1,210 @@
|
||||
#pragma once
|
||||
#include "imgui.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sstream>
|
||||
#include <cstdio>
|
||||
|
||||
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<InputTextCallbackData*>(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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
32
editor/tests/step122_test.cpp
Normal file
32
editor/tests/step122_test.cpp
Normal file
@@ -0,0 +1,32 @@
|
||||
// Step 122 TDD Test: Terminal panel command building + output append
|
||||
#include "TerminalPanel.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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user