Step 122: add integrated terminal panel

This commit is contained in:
Bill
2026-02-09 15:04:25 -07:00
parent 1fd567fb20
commit d5d2174c7b
6 changed files with 264 additions and 2 deletions

View File

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

View File

@@ -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
View 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);
}
};

View File

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

View 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;
}

View File

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