diff --git a/cpp/functions/core/ansi_parser.cpp b/cpp/functions/core/ansi_parser.cpp new file mode 100644 index 00000000..7279db08 --- /dev/null +++ b/cpp/functions/core/ansi_parser.cpp @@ -0,0 +1,250 @@ +#include "core/ansi_parser.h" + +namespace fn_term { + +// Paleta xterm-16 en ABGR (little-endian: R,G,B,A en memoria = RGBA8888 en lectura). +// Index 0-7 colores normales, 8-15 brillantes, 16 = default. +const uint32_t kPalette16[17] = { + 0xFF000000, // 0 black + 0xFF0000AA, // 1 red + 0xFF00AA00, // 2 green + 0xFF00AAAA, // 3 yellow (dark) + 0xFFAA0000, // 4 blue + 0xFFAA00AA, // 5 magenta + 0xFFAAAA00, // 6 cyan + 0xFFAAAAAA, // 7 white (light grey) + 0xFF555555, // 8 bright black (dark grey) + 0xFF5555FF, // 9 bright red + 0xFF55FF55, // 10 bright green + 0xFF55FFFF, // 11 bright yellow + 0xFFFF5555, // 12 bright blue + 0xFFFF55FF, // 13 bright magenta + 0xFFFFFF55, // 14 bright cyan + 0xFFFFFFFF, // 15 bright white + 0xFFCCCCCC, // 16 default (light grey) +}; + +AnsiParser::AnsiParser() { + for (int i = 0; i < kMaxParams; i++) params_[i] = 0; +} + +void AnsiParser::reset() { + state_ = State::Ground; + cur_fg_ = kColorDefault; + cur_bg_ = kColorDefault; + cur_bold_ = 0; + param_count_ = 0; + cur_param_ = 0; + for (int i = 0; i < kMaxParams; i++) params_[i] = 0; +} + +void AnsiParser::feed(const char* data, size_t n, + const std::function& cb) { + for (size_t i = 0; i < n; i++) { + process_byte(static_cast(data[i]), cb); + } +} + +void AnsiParser::flush_param() { + if (param_count_ < kMaxParams) { + params_[param_count_++] = cur_param_; + } + cur_param_ = 0; +} + +void AnsiParser::apply_sgr(const std::function& /*cb*/) { + // Si no hay params → reset (SGR 0). + int n = (param_count_ == 0) ? 1 : param_count_; + const int* p = (param_count_ == 0) ? nullptr : params_; + + for (int i = 0; i < n; i++) { + int code = (p ? p[i] : 0); + if (code == 0) { + // Reset todo + cur_fg_ = kColorDefault; + cur_bg_ = kColorDefault; + cur_bold_ = 0; + } else if (code == 1) { + cur_bold_ = 1; + } else if (code == 22) { + cur_bold_ = 0; + } else if (code >= 30 && code <= 37) { + cur_fg_ = static_cast(code - 30); + } else if (code == 39) { + cur_fg_ = kColorDefault; + } else if (code >= 40 && code <= 47) { + cur_bg_ = static_cast(code - 40); + } else if (code == 49) { + cur_bg_ = kColorDefault; + } else if (code >= 90 && code <= 97) { + cur_fg_ = static_cast(code - 90 + 8); + } else if (code >= 100 && code <= 107) { + cur_bg_ = static_cast(code - 100 + 8); + } + // Otros códigos ignorados silenciosamente (v1 anti-scope). + } +} + +void AnsiParser::dispatch_csi(unsigned char final_byte, + const std::function& cb) { + AnsiEvent ev; + int p0 = (param_count_ > 0) ? params_[0] : 0; + int p1 = (param_count_ > 1) ? params_[1] : 0; + + switch (final_byte) { + case 'H': case 'f': { + // CUP: ESC [ row ; col H (1-based → convertir a 0-based) + ev.type = AnsiEventType::CursorAbsolute; + ev.cursor_abs.row = (p0 > 0 ? p0 - 1 : 0); + ev.cursor_abs.col = (p1 > 0 ? p1 - 1 : 0); + cb(ev); + break; + } + case 'A': { + ev.type = AnsiEventType::CursorMove; + ev.cursor_rel.dir = CursorDir::Up; + ev.cursor_rel.n = (p0 > 0 ? p0 : 1); + cb(ev); + break; + } + case 'B': { + ev.type = AnsiEventType::CursorMove; + ev.cursor_rel.dir = CursorDir::Down; + ev.cursor_rel.n = (p0 > 0 ? p0 : 1); + cb(ev); + break; + } + case 'C': { + ev.type = AnsiEventType::CursorMove; + ev.cursor_rel.dir = CursorDir::Forward; + ev.cursor_rel.n = (p0 > 0 ? p0 : 1); + cb(ev); + break; + } + case 'D': { + ev.type = AnsiEventType::CursorMove; + ev.cursor_rel.dir = CursorDir::Back; + ev.cursor_rel.n = (p0 > 0 ? p0 : 1); + cb(ev); + break; + } + case 'J': { + // ED: erase in display. Solo param=2 (clear screen) soportado en v1. + if (p0 == 2 || p0 == 0) { + ev.type = AnsiEventType::EraseDisplay; + cb(ev); + } + break; + } + case 'K': { + // EL: erase in line. Solo param=2 (clear entire line) soportado en v1. + if (p0 == 2 || p0 == 0) { + ev.type = AnsiEventType::EraseLine; + cb(ev); + } + break; + } + case 'm': { + // SGR: select graphic rendition. + apply_sgr(cb); + break; + } + default: + // Secuencia CSI desconocida — ignorar silenciosamente. + break; + } +} + +void AnsiParser::process_byte(unsigned char c, + const std::function& cb) { + switch (state_) { + + case State::Ground: + if (c == 0x1B) { + state_ = State::Escape; + } else if (c == '\r') { + AnsiEvent ev; ev.type = AnsiEventType::CarriageReturn; cb(ev); + } else if (c == '\n') { + AnsiEvent ev; ev.type = AnsiEventType::Newline; cb(ev); + } else if (c == '\x08') { + AnsiEvent ev; ev.type = AnsiEventType::Backspace; cb(ev); + } else if (c >= 0x20 && c < 0x7F) { + // ASCII imprimible. + AnsiEvent ev; + ev.type = AnsiEventType::Char; + ev.cell.ch = static_cast(c); + ev.cell.fg = cur_fg_; + ev.cell.bg = cur_bg_; + ev.cell.bold = cur_bold_; + cb(ev); + } else if (c >= 0xC0) { + // Inicio de secuencia UTF-8 multi-byte. + // En v1 mapeamos todo >= 0x80 a '?' para evitar complejidad Unicode. + // TODO(0132): soporte Unicode completo en v2. + AnsiEvent ev; + ev.type = AnsiEventType::Char; + ev.cell.ch = U'?'; + ev.cell.fg = cur_fg_; + ev.cell.bg = cur_bg_; + ev.cell.bold = cur_bold_; + cb(ev); + } else if (c >= 0x80 && c < 0xC0) { + // Continuation byte de UTF-8 → ignorar (fragmento de multi-byte). + } + // Otros control bytes (0x00-0x1F excl \r\n\x08\x1B) → ignorar. + break; + + case State::Escape: + if (c == '[') { + state_ = State::CsiEntry; + param_count_ = 0; + cur_param_ = 0; + } else { + // Secuencia ESC desconocida (no-CSI) → volver a Ground. + state_ = State::Ground; + } + break; + + case State::CsiEntry: + // Primer byte del CSI: puede ser un dígito, ';' o el final byte. + if (c >= '0' && c <= '9') { + cur_param_ = c - '0'; + state_ = State::CsiParam; + } else if (c == ';') { + // Parámetro vacío → valor 0. + flush_param(); + cur_param_ = 0; + state_ = State::CsiParam; + } else if (c >= 0x40 && c <= 0x7E) { + // Byte final inmediato sin parámetros. + dispatch_csi(c, cb); + state_ = State::Ground; + } else if (c == '?') { + // Modos privados (e.g. ESC[?25l cursor hide) → ignorar hasta final byte. + // Permanecemos en CsiEntry esperando el final byte. + } else { + // Byte inesperado → abortar CSI. + state_ = State::Ground; + } + break; + + case State::CsiParam: + if (c >= '0' && c <= '9') { + cur_param_ = cur_param_ * 10 + (c - '0'); + } else if (c == ';') { + flush_param(); + cur_param_ = 0; + } else if (c >= 0x40 && c <= 0x7E) { + // Byte final: flush último param y despachar. + flush_param(); + dispatch_csi(c, cb); + state_ = State::Ground; + } else { + // Byte inesperado → abortar. + state_ = State::Ground; + } + break; + } +} + +} // namespace fn_term diff --git a/cpp/functions/core/ansi_parser.h b/cpp/functions/core/ansi_parser.h new file mode 100644 index 00000000..b3e86d86 --- /dev/null +++ b/cpp/functions/core/ansi_parser.h @@ -0,0 +1,131 @@ +#pragma once + +// ansi_parser — parser ANSI/VT100 minimo, byte-a-byte, sin heap allocs por evento. +// +// Soporta: +// SGR: colores FG/BG 16 colores (30-37, 40-47, 90-97, 100-107), bold (1), reset (0). +// CUP (H): cursor absolute position row,col. +// CUU (A), CUD (B), CUF (C), CUB (D): cursor relative moves. +// ED (J): erase in display (param=2 → clear screen). +// EL (K): erase in line (param=2 → clear line). +// Carriage Return (\r), Newline (\n), Backspace (\x08). +// Text: caracteres imprimibles (excl. control bytes). +// +// No soportado (v1, anti-scope): +// 256/24-bit color, italics, underline, Unicode wide, OSC, DCS, SOS, PM, APC, +// CSI sequences > 16 parametros, character sets (SI/SO), private modes. +// +// Uso: +// fn_term::AnsiParser p; +// p.feed(data, n, [](const fn_term::AnsiEvent& ev) { /* handle */ }); +// +// Thread-safety: NO. Cada instancia debe usarse desde un solo hilo. + +#include +#include +#include + +namespace fn_term { + +// Codigos de color ANSI → index 0-15 en paleta CGA/xterm-16. +// 0-7: colores normales (black, red, green, yellow, blue, magenta, cyan, white) +// 8-15: colores brillantes (idem + bright) +// 16: color por defecto (FG o BG) +static constexpr uint8_t kColorDefault = 16; + +// Paleta xterm-16 en RGBA8888 (A=0xFF), misma que la mayoria de terminales. +// Acceso: kPalette16[index], index in [0,15]. +extern const uint32_t kPalette16[17]; // [16] = color "default" (blanco/negro) + +// Una celda del terminal virtual. +struct AnsiCell { + char32_t ch = U' '; // codepoint Unicode (solo BMP en v1) + uint8_t fg = kColorDefault; // indice paleta 0-16 (16 = default) + uint8_t bg = kColorDefault; + uint8_t bold = 0; + uint8_t _pad = 0; +}; + +// Tipos de evento emitidos por el parser. +enum class AnsiEventType : uint8_t { + Char, // un caracter imprimible (AnsiEvent.cell.ch valido) + CursorMove, // AnsiEvent.row / .col delta o absoluto segun subtype + CursorAbsolute, // CUP: posicion absoluta 0-based (row, col) + EraseDisplay, // ED(2): limpiar pantalla completa + EraseLine, // EL(2): limpiar linea actual completa + CarriageReturn, // \r + Newline, // \n + Backspace, // \x08 +}; + +// Subtipos de CursorMove. +enum class CursorDir : uint8_t { Up, Down, Forward, Back }; + +struct AnsiEvent { + AnsiEventType type; + union { + AnsiCell cell; // type == Char + struct { + CursorDir dir; + int n; // pasos (>= 1) + } cursor_rel; // type == CursorMove + struct { + int row; // 0-based + int col; // 0-based + } cursor_abs; // type == CursorAbsolute + // EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace: sin datos extra. + }; + + AnsiEvent() : type(AnsiEventType::Char), cell{} {} +}; + +// Clase principal. Stateful — mantiene el estado del parser entre llamadas a feed(). +class AnsiParser { +public: + AnsiParser(); + ~AnsiParser() = default; + AnsiParser(const AnsiParser&) = delete; + AnsiParser& operator=(const AnsiParser&) = delete; + + // Procesa `n` bytes de `data`. Emite eventos via `cb` en orden. + // cb puede ser llamada 0 o más veces por feed(). + // Sin alloc heap por byte ni por evento. + void feed(const char* data, size_t n, + const std::function& cb); + + // Resetea el estado del parser (útil al limpiar pantalla). + void reset(); + + // Atributos SGR actuales (se actualizan al procesar secuencias SGR). + uint8_t current_fg() const { return cur_fg_; } + uint8_t current_bg() const { return cur_bg_; } + uint8_t current_bold() const { return cur_bold_; } + +private: + enum class State : uint8_t { + Ground, // estado normal: procesar texto + Escape, // recibido ESC + CsiEntry, // recibido ESC [ + CsiParam, // acumulando parametros CSI + }; + + State state_ = State::Ground; + uint8_t cur_fg_ = kColorDefault; + uint8_t cur_bg_ = kColorDefault; + uint8_t cur_bold_ = 0; + + // Buffer de parametros CSI (max 16 params de 4 digitos cada uno). + static constexpr int kMaxParams = 16; + int params_[kMaxParams]; + int param_count_ = 0; + int cur_param_ = 0; // valor del param que se esta acumulando + + void process_byte(unsigned char c, + const std::function& cb); + void flush_param(); + void dispatch_csi(unsigned char final_byte, + const std::function& cb); + void apply_sgr(const std::function& cb); +}; + +} // namespace fn_term diff --git a/cpp/functions/core/ansi_parser.md b/cpp/functions/core/ansi_parser.md new file mode 100644 index 00000000..eba2a1a5 --- /dev/null +++ b/cpp/functions/core/ansi_parser.md @@ -0,0 +1,97 @@ +--- +name: ansi_parser +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "class fn_term::AnsiParser { void feed(const char* data, size_t n, const std::function& cb); void reset(); uint8_t current_fg() const; uint8_t current_bg() const; uint8_t current_bold() const; }" +description: "Parser ANSI/VT100 minimo byte-a-byte sin alloc heap por evento. Soporta SGR colores FG/BG 16-color + bold + reset, cursor moves (CUP/CUU/CUD/CUF/CUB), erase display/line (ED 2, EL 2), CR/LF/BS. Statemachine simple con 4 estados. Emite AnsiEvent via callback." +tags: [ansi, vt100, terminal, parser, pure, state-machine, cpp-dashboard-viz] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [cstddef, cstdint, functional] +tested: true +tests: + - "SGR reset sets default colors" + - "SGR fg color 31 sets red" + - "SGR bg color 44 sets blue background" + - "SGR bright fg 91 sets bright red" + - "SGR bold sets bold flag" + - "cursor CUU moves up N" + - "cursor CUF moves forward N" + - "cursor CUP absolute position" + - "erase display ED 2" + - "erase line EL 2" + - "mixed text and SGR sequence" + - "newline and carriage return" +test_file_path: "cpp/tests/test_ansi_parser.cpp" +file_path: "cpp/functions/core/ansi_parser.cpp" +framework: "" +params: + - name: data + desc: "Puntero al buffer de bytes a procesar (output crudo de PTY/ConPTY)" + - name: n + desc: "Numero de bytes en data" + - name: cb + desc: "Callback invocado por cada evento emitido. Sin alloc — el AnsiEvent vive en el stack del parser" +output: "Sin retorno directo. Eventos emitidos via callback: AnsiEventType::Char (caracter + atributos SGR actuales), CursorMove (relativo), CursorAbsolute (CUP), EraseDisplay, EraseLine, CarriageReturn, Newline, Backspace" +notes: "Usado por terminal_panel_cpp_viz como paso de parseo del output PTY. Anti-scope v1: sin 256/24-bit color, sin italics/underline, sin Unicode wide, sin OSC/DCS. UTF-8 multi-byte se mapea a '?' en v1." +--- + +# ansi_parser + +Parser ANSI/VT100 minimo para el modulo `terminal_panel`. Sin heap allocs por byte procesado — la maquina de estados vive en el objeto y los `AnsiEvent` se emiten por callback en el stack del caller. + +## Ejemplo + +```cpp +#include "core/ansi_parser.h" + +fn_term::AnsiParser parser; +std::string output; + +// Procesar output crudo de PTY: +parser.feed(pty_buf, bytes_read, [&](const fn_term::AnsiEvent& ev) { + if (ev.type == fn_term::AnsiEventType::Char) { + // ev.cell.ch = codepoint, ev.cell.fg = color index 0-16 + output += static_cast(ev.cell.ch); + } else if (ev.type == fn_term::AnsiEventType::Newline) { + output += '\n'; + } +}); +``` + +## Cuando usarla + +Cuando procesas output crudo de un PTY (Linux forkpty) o ConPTY (Windows) y necesitas extraer texto + atributos de color para renderizar en ImGui con `PushStyleColor`. Es la capa de parseo de `terminal_panel`. + +## Secuencias soportadas (v1) + +| Tipo | Secuencia | AnsiEventType | +|------|-----------|---------------| +| Texto ASCII | bytes 0x20-0x7E | Char | +| CR | `\r` (0x0D) | CarriageReturn | +| LF | `\n` (0x0A) | Newline | +| BS | `\x08` | Backspace | +| SGR reset | `ESC[0m` o `ESC[m` | (actualiza estado interno) | +| SGR bold | `ESC[1m` | (actualiza estado interno) | +| SGR FG 16 | `ESC[30-37m`, `ESC[90-97m` | (actualiza estado interno) | +| SGR BG 16 | `ESC[40-47m`, `ESC[100-107m` | (actualiza estado interno) | +| Cursor UP | `ESC[nA` | CursorMove (Up, n) | +| Cursor DOWN | `ESC[nB` | CursorMove (Down, n) | +| Cursor FWD | `ESC[nC` | CursorMove (Forward, n) | +| Cursor BACK | `ESC[nD` | CursorMove (Back, n) | +| CUP | `ESC[r;cH` | CursorAbsolute (0-based) | +| ED(2) | `ESC[2J` | EraseDisplay | +| EL(2) | `ESC[2K` | EraseLine | + +## Gotchas + +- Anti-scope v1: no 256-color (`ESC[38;5;Nm`), no 24-bit color, no italics/underline, no curses pesados. +- UTF-8 multi-byte: bytes de continuacion 0x80-0xBF ignorados; inicio 0xC0+ emite `?`. Soporte completo en v2. +- No thread-safe: cada instancia debe usarse desde un solo hilo (el reader thread del PTY). +- `kPalette16[16]` es el color "default" (gris claro). El caller decide si usar el color del tema o la paleta fija. diff --git a/cpp/functions/viz/terminal_panel/terminal_panel.cpp b/cpp/functions/viz/terminal_panel/terminal_panel.cpp new file mode 100644 index 00000000..6a3c6410 --- /dev/null +++ b/cpp/functions/viz/terminal_panel/terminal_panel.cpp @@ -0,0 +1,287 @@ +// terminal_panel.cpp — render + process_output + shared logic. +// Los backends (open/close/send) viven en terminal_panel_linux.cpp +// y terminal_panel_windows.cpp respectivamente. + +#include "viz/terminal_panel/terminal_panel.h" +#include "core/logger.h" +#include "core/tokens.h" +#include "imgui.h" + +#include +#include +#include + +namespace fn_term { + +namespace { + +// Convierte índice de color fn_term (0-16) a ImU32 RGBA para ImGui. +// Usa la paleta kPalette16; fg=16 (default) → color de texto del tema ImGui. +ImU32 color_to_imu32(uint8_t idx, bool is_fg) { + if (idx == kColorDefault) { + // Usar color del tema: FG → Text, BG → transparente. + if (is_fg) return ImGui::GetColorU32(ImGuiCol_Text); + return IM_COL32(0, 0, 0, 0); // transparente + } + // kPalette16 está en formato ABGR (little-endian), ImU32 también es ABGR en ImGui. + return static_cast(kPalette16[idx]); +} + +// Renderiza una línea del scrollback con colores. +// Toma la línea como vector y escribe chunks de mismo color. +void render_line(const TermLine& line) { + if (line.empty()) { + ImGui::NewLine(); + return; + } + + // Agrupar celdas consecutivas con mismo fg/bg/bold y emitir como texto. + // Usamos un buffer temporal de la pila para evitar alloacs por línea. + static char buf[4096]; + + size_t i = 0; + while (i < line.size()) { + uint8_t fg = line[i].fg; + uint8_t bg = line[i].bg; + // uint8_t bold = line[i].bold; // TODO(0132): bold rendering v2 + + // Acumular chars con mismo estilo. + size_t j = i; + int pos = 0; + while (j < line.size() && line[j].fg == fg && line[j].bg == bg) { + char32_t ch = line[j].ch; + if (ch >= 0x20 && ch < 0x7F && pos < (int)sizeof(buf) - 2) { + buf[pos++] = static_cast(ch); + } else if (ch != U' ' && pos < (int)sizeof(buf) - 2) { + buf[pos++] = '?'; // no-ASCII en v1 + } else if (pos < (int)sizeof(buf) - 2) { + buf[pos++] = ' '; + } + j++; + } + buf[pos] = '\0'; + + // Push color FG. + ImU32 fg_col = color_to_imu32(fg, true); + bool has_fg = (fg != kColorDefault); + if (has_fg) ImGui::PushStyleColor(ImGuiCol_Text, fg_col); + + // Fondo: si BG definido, usar InvisibleButton + DrawList rect antes del texto. + // En v1 simplificamos: solo coloreamos el texto (FG). BG requiere DrawList. + // TODO(0132): renderizar celdas BG con InvisibleButton + DrawList en v2. + + ImGui::TextUnformatted(buf, buf + pos); + + if (has_fg) ImGui::PopStyleColor(); + + // Continuar en la misma línea si hay más celdas. + if (j < line.size()) ImGui::SameLine(0.0f, 0.0f); + + i = j; + } +} + +} // namespace + +TerminalPanel::TerminalPanel() { + // Reservar una línea inicial vacía. + lines.emplace_back(); +} + +TerminalPanel::~TerminalPanel() { + if (is_open()) close(*this); +} + +// --------------------------------------------------------------------------- +// process_output — llamado desde el reader thread. +// Parsea los bytes via AnsiParser y actualiza el scrollback buffer. +// --------------------------------------------------------------------------- +void process_output(TerminalPanel& panel, const char* data, size_t n) { + std::lock_guard lk(panel.buf_mutex); + + panel.parser.feed(data, n, [&](const AnsiEvent& ev) { + switch (ev.type) { + case AnsiEventType::Char: { + // Asegurar que tenemos al menos cur_row+1 filas. + while ((int)panel.lines.size() <= panel.cur_row) + panel.lines.emplace_back(); + TermLine& line = panel.lines[panel.cur_row]; + // Asegurar que la fila tiene al menos cur_col+1 celdas. + while ((int)line.size() <= panel.cur_col) + line.push_back(AnsiCell{}); + line[panel.cur_col] = ev.cell; + panel.cur_col++; + break; + } + case AnsiEventType::Newline: { + panel.cur_row++; + // Scrollback circular: si excede el límite, eliminar la primera fila. + while ((int)panel.lines.size() <= panel.cur_row) + panel.lines.emplace_back(); + if ((int)panel.lines.size() > panel.scrollback_lines) { + int excess = (int)panel.lines.size() - panel.scrollback_lines; + panel.lines.erase(panel.lines.begin(), + panel.lines.begin() + excess); + panel.cur_row -= excess; + if (panel.cur_row < 0) panel.cur_row = 0; + } + panel.scroll_to_bottom = true; + break; + } + case AnsiEventType::CarriageReturn: { + panel.cur_col = 0; + break; + } + case AnsiEventType::Backspace: { + if (panel.cur_col > 0) panel.cur_col--; + break; + } + case AnsiEventType::CursorAbsolute: { + panel.cur_row = std::max(0, ev.cursor_abs.row); + panel.cur_col = std::max(0, ev.cursor_abs.col); + // Extender líneas si necesario. + while ((int)panel.lines.size() <= panel.cur_row) + panel.lines.emplace_back(); + break; + } + case AnsiEventType::CursorMove: { + switch (ev.cursor_rel.dir) { + case CursorDir::Up: + panel.cur_row = std::max(0, panel.cur_row - ev.cursor_rel.n); + break; + case CursorDir::Down: + panel.cur_row += ev.cursor_rel.n; + while ((int)panel.lines.size() <= panel.cur_row) + panel.lines.emplace_back(); + break; + case CursorDir::Forward: + panel.cur_col += ev.cursor_rel.n; + break; + case CursorDir::Back: + panel.cur_col = std::max(0, panel.cur_col - ev.cursor_rel.n); + break; + } + break; + } + case AnsiEventType::EraseDisplay: { + panel.lines.clear(); + panel.lines.emplace_back(); + panel.cur_row = 0; + panel.cur_col = 0; + panel.parser.reset(); + break; + } + case AnsiEventType::EraseLine: { + while ((int)panel.lines.size() <= panel.cur_row) + panel.lines.emplace_back(); + panel.lines[panel.cur_row].clear(); + panel.cur_col = 0; + break; + } + } + }); +} + +// --------------------------------------------------------------------------- +// render — debe llamarse dentro de un frame ImGui activo. +// --------------------------------------------------------------------------- +void render(TerminalPanel& panel) { + // --- Toolbar --- + ImGui::PushID("##term_toolbar"); + + if (ImGui::SmallButton("Clear")) { + std::lock_guard lk(panel.buf_mutex); + panel.lines.clear(); + panel.lines.emplace_back(); + panel.cur_row = 0; + panel.cur_col = 0; + } + ImGui::SameLine(); + + if (ImGui::SmallButton("Copy")) { + // Copiar todo el scrollback como texto plano al portapapeles. + std::string text; + std::lock_guard lk(panel.buf_mutex); + for (const auto& line : panel.lines) { + for (const auto& cell : line) { + if (cell.ch >= 0x20 && cell.ch < 0x7F) + text += static_cast(cell.ch); + else if (cell.ch != U' ') + text += '?'; + else + text += ' '; + } + text += '\n'; + } + ImGui::SetClipboardText(text.c_str()); + } + ImGui::SameLine(); + + if (ImGui::SmallButton("Reset") && panel.is_open()) { + fn_term::close(panel); + fn_term::open(panel); + } + ImGui::SameLine(); + + bool lock = !panel.scroll_to_bottom; + if (ImGui::Checkbox("Lock scroll", &lock)) { + panel.scroll_to_bottom = !lock; + } + ImGui::SameLine(); + + // Indicador de estado del proceso. + if (!panel.is_open()) { + ImGui::TextDisabled("[closed]"); + } else if (panel.process_exited.load()) { + ImGui::TextDisabled("[exited %d]", panel.exit_code); + } else { + ImGui::TextDisabled("[running]"); + } + + ImGui::PopID(); + + // --- Scrollback area --- + ImVec2 avail = ImGui::GetContentRegionAvail(); + float child_h = panel.readonly + ? avail.y + : std::max(avail.y - ImGui::GetFrameHeightWithSpacing() - 4.0f, 32.0f); + + ImGui::BeginChild("##term_scroll", ImVec2(0, child_h), + ImGuiChildFlags_Borders, + ImGuiWindowFlags_HorizontalScrollbar); + + { + std::lock_guard lk(panel.buf_mutex); + // Usar un clipper para evitar renderizar líneas fuera de vista. + ImGuiListClipper clipper; + clipper.Begin((int)panel.lines.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { + render_line(panel.lines[i]); + } + } + clipper.End(); + } + + if (panel.scroll_to_bottom && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f) { + ImGui::SetScrollHereY(1.0f); + } + + ImGui::EndChild(); + + // --- Input box (si no es readonly) --- + if (!panel.readonly && panel.is_open()) { + static char s_input[1024] = {}; + ImGui::SetNextItemWidth(-1.0f); + bool enter = ImGui::InputText("##term_input", s_input, sizeof(s_input), + ImGuiInputTextFlags_EnterReturnsTrue); + if (enter) { + std::string cmd = std::string(s_input) + "\n"; + fn_term::send(panel, cmd); + s_input[0] = '\0'; + ImGui::SetKeyboardFocusHere(-1); + } + } +} + +} // namespace fn_term diff --git a/cpp/functions/viz/terminal_panel/terminal_panel.h b/cpp/functions/viz/terminal_panel/terminal_panel.h new file mode 100644 index 00000000..b833fee7 --- /dev/null +++ b/cpp/functions/viz/terminal_panel/terminal_panel.h @@ -0,0 +1,111 @@ +#pragma once + +// terminal_panel — emulador TTY embebible en ImGui. +// +// Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows) y +// renderiza su output en un child window ImGui con soporte basico de ANSI: +// colores FG/BG 16-color, bold, cursor pos, clear screen/line. +// +// Uso basico: +// static fn_term::TerminalPanel term; +// term.shell = "/bin/bash"; +// +// if (!term.is_open()) fn_term::open(term); +// fn_term::render(term); +// if (!term.readonly) fn_term::send(term, "ls\n"); +// // Al cerrar: +// fn_term::close(term); +// +// Thread-safety: open/render/send/close deben llamarse desde el hilo ImGui. +// El reader thread interno es gestionado por la implementacion. +// +// Plataformas: +// Linux/macOS: terminal_panel_linux.cpp (forkpty + read no-blocking en thread) +// Windows: terminal_panel_windows.cpp (ConPTY CreatePseudoConsole) + +#include "core/ansi_parser.h" + +#include +#include +#include +#include +#include +#include + +namespace fn_term { + +// Una linea del scrollback: vector de celdas ya parseadas. +using TermLine = std::vector; + +// Configuracion y estado del panel. +struct TerminalPanel { + // --- Config (set antes de open(), no cambiar en vivo) --- + std::string shell; // "" → auto-detect (/bin/bash linux, cmd.exe windows) + std::string cwd; // "" → directorio actual del proceso padre + std::vector env; // KEY=VAL adicionales al entorno heredado + int scrollback_lines = 5000; // max filas en el ring buffer + bool readonly = false; // si true, no reenvía input del teclado + + // --- Estado interno (gestionado por open/close/render) --- + // No modificar directamente. + + // Proceso hijo + int child_pid = -1; // Linux: PID del hijo; -1 si no abierto + int master_fd = -1; // Linux: fd del extremo master del PTY + void* proc_handle = nullptr; // Windows: HANDLE del proceso hijo (HANDLE) + void* pty_handle = nullptr; // Windows: HPCON (ConPTY handle) + void* pipe_read = nullptr; // Windows: HANDLE pipe de lectura + void* pipe_write = nullptr; // Windows: HANDLE pipe de escritura (→ stdin del hijo) + + // Reader thread + std::thread reader_thread; + std::atomic reader_running{false}; + + // Scrollback buffer (protegido por mutex) + mutable std::mutex buf_mutex; + std::vector lines; // buffer circular de lineas + int cur_row = 0; // fila del cursor dentro de `lines` + int cur_col = 0; // columna del cursor + bool scroll_to_bottom = true; + + // Parser ANSI (solo lo toca el reader thread) + AnsiParser parser; + + // Flag: proceso hijo terminó + std::atomic process_exited{false}; + int exit_code = 0; + + // ctor/dtor + TerminalPanel(); + ~TerminalPanel(); + TerminalPanel(const TerminalPanel&) = delete; + TerminalPanel& operator=(const TerminalPanel&) = delete; + + bool is_open() const { return master_fd >= 0 || pipe_read != nullptr; } +}; + +// Abre el proceso hijo y arranca el reader thread. +// Llama una sola vez antes del primer render. +// Si falla, loguea via fn_log::log_error y deja is_open() == false. +void open(TerminalPanel& panel); + +// Renderiza el terminal en el area disponible de ImGui. +// Debe llamarse dentro de un frame ImGui activo. +// Dibuja toolbar (clear, copy, reset, scroll-lock) + scrollback + input. +void render(TerminalPanel& panel); + +// Envía texto al stdin del proceso hijo. +// No-op si !is_open() o readonly. +void send(TerminalPanel& panel, const std::string& text); + +// Cierra el proceso hijo, espera al reader thread y libera recursos. +void close(TerminalPanel& panel); + +// ---- Internals usados por los backends Linux/Windows ---- +// (No llamar directamente desde apps.) + +// Procesa un chunk de bytes del PTY y los añade al scrollback. +// Llamado desde el reader thread. Thread-safe via buf_mutex. +void process_output(TerminalPanel& panel, const char* data, size_t n); + +} // namespace fn_term diff --git a/cpp/functions/viz/terminal_panel/terminal_panel.md b/cpp/functions/viz/terminal_panel/terminal_panel.md new file mode 100644 index 00000000..ebcb1883 --- /dev/null +++ b/cpp/functions/viz/terminal_panel/terminal_panel.md @@ -0,0 +1,76 @@ +--- +name: terminal_panel +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: impure +signature: "void fn_term::open(fn_term::TerminalPanel& panel); void fn_term::render(fn_term::TerminalPanel& panel); void fn_term::send(fn_term::TerminalPanel& panel, const std::string& text); void fn_term::close(fn_term::TerminalPanel& panel);" +description: "Emulador TTY embebible en ImGui. Arranca un proceso hijo via PTY (Linux: forkpty) o ConPTY (Windows 10 v1809+), renderiza el scrollback con colores ANSI 16-color, toolbar (clear/copy/reset/scroll-lock) e input box. Scrollback circular configurable. Soporte readonly para tail-only." +tags: [terminal, pty, conpty, imgui, viz, ansi, shell, cpp-dashboard-viz] +uses_functions: [ansi_parser_cpp_core, logger_cpp_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [atomic, functional, mutex, string, thread, vector] +tested: true +tests: + - "smoke: spawn echo hello and exit, scrollback contains hello" +test_file_path: "cpp/tests/test_terminal_panel_smoke.cpp" +file_path: "cpp/functions/viz/terminal_panel/terminal_panel.cpp" +framework: imgui +params: + - name: panel + desc: "Struct TerminalPanel con config (shell, cwd, env, scrollback_lines, readonly) y estado interno gestionado por open/close/render" +output: "render() dibuja toolbar + scrollback con colores ANSI + input box en el area ImGui disponible. open() arranca el proceso hijo y el reader thread. send() escribe texto al stdin del hijo. close() mata el proceso y libera recursos." +notes: "Linux: requiere -lutil (libutil) para forkpty. Windows: requiere Windows SDK >= 17763 (v1809) para ConPTY. Si el SDK es anterior, open() loguea error y deja is_open()==false. Anti-scope v1: sin tabs multiples, sin SSH, sin curses pesados (vim/htop)." +--- + +# terminal_panel + +Emulador TTY embebible en ImGui. Util para: tail de logs en una app de monitoring, ejecutar comandos shell desde un panel de kanban, ver output de compilaciones, consola de debug de agentes. + +## Ejemplo + +```cpp +#include "viz/terminal_panel/terminal_panel.h" + +static fn_term::TerminalPanel s_term; + +void render_panel() { + // Abrir al primer frame. + if (!s_term.is_open()) { + s_term.shell = "/bin/bash"; + s_term.scrollback_lines = 2000; + fn_term::open(s_term); + } + fn_term::render(s_term); +} + +// Tail readonly de un log: +static fn_term::TerminalPanel s_log_tail; + +void render_log_tail() { + if (!s_log_tail.is_open()) { + s_log_tail.shell = "/bin/bash"; + s_log_tail.readonly = true; + fn_term::open(s_log_tail); + fn_term::send(s_log_tail, "tail -f /tmp/agent.log\n"); + } + fn_term::render(s_log_tail); +} +``` + +## Cuando usarla + +Cuando necesitas ver output crudo de un proceso (shell, compilacion, curl, tail) sin salir de la app ImGui. Alternativa a abrir un terminal externo. Especialmente util en apps de monitoring (services_monitor, agents_dashboard) y kanban panels de build. + +## Gotchas + +- **Linux**: el CMakeLists del consumidor debe linkar `-lutil` (o `target_link_libraries(... util)`) para resolver `forkpty`. +- **Windows**: requiere Windows 10 v1809+ (SDK >= 17763). Si el SDK es anterior, `open()` deja el panel cerrado y loguea error — no hay panic ni crash. +- **Anti-scope v1**: sin soporte de curses pesados (vim, htop, top). El parser ANSI maneja SGR color + cursor básico; programas que usen el modo altscreen o muchas secuencias de cursor se verán mal. +- **Scrollback circular**: cuando `lines.size() > scrollback_lines`, se elimina la primera fila. Esto puede causar saltos visuales si el contenido se está acumulando muy rápido (ej. `yes "x"`). En v1 el target es 60fps con scrollback de 5000 líneas. +- **Thread safety**: `render()` toma el `buf_mutex` por el tiempo del render de cada frame. El reader thread también lo toma al actualizar el buffer. En condiciones normales no hay contención significativa. +- **readonly**: si `true`, no se renderiza el input box y `send()` es no-op. Útil para `tail -f` o procesos que no necesitan stdin. diff --git a/cpp/functions/viz/terminal_panel/terminal_panel_linux.cpp b/cpp/functions/viz/terminal_panel/terminal_panel_linux.cpp new file mode 100644 index 00000000..dc49b686 --- /dev/null +++ b/cpp/functions/viz/terminal_panel/terminal_panel_linux.cpp @@ -0,0 +1,180 @@ +// terminal_panel_linux.cpp — backend PTY para Linux/macOS. +// Compilado solo en plataformas no-Windows. +// +// Implementacion: forkpty() crea el proceso hijo con un PTY maestro/esclavo. +// Un thread de lectura en background lee del fd maestro de forma no-bloqueante +// y llama process_output() para actualizar el scrollback buffer. + +#ifndef _WIN32 + +#include "viz/terminal_panel/terminal_panel.h" +#include "core/logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include // forkpty — requiere -lutil en Linux + +namespace fn_term { + +namespace { + +// Detecta el shell por defecto: $SHELL o /bin/bash como fallback. +std::string default_shell() { + const char* sh = std::getenv("SHELL"); + return sh ? sh : "/bin/bash"; +} + +// Thread de lectura: lee del fd maestro del PTY en bloques y +// llama process_output. Termina cuando el proceso hijo cierra el PTY +// (read devuelve 0 o EIO) o cuando reader_running se pone a false. +void reader_thread_fn(TerminalPanel* panel) { + char buf[4096]; + while (panel->reader_running.load()) { + ssize_t n = ::read(panel->master_fd, buf, sizeof(buf)); + if (n > 0) { + process_output(*panel, buf, static_cast(n)); + } else if (n == 0) { + // EOF: el proceso hijo cerró el PTY. + break; + } else { + // EIO ocurre cuando el proceso hijo sale y cierra el esclavo. + if (errno == EIO || errno == EBADF) break; + if (errno == EINTR) continue; + // Otro error transitorio: esperar un poco y reintentar. + usleep(5000); + } + } + + // Recolectar el código de salida del hijo. + if (panel->child_pid > 0) { + int status = 0; + ::waitpid(panel->child_pid, &status, WNOHANG); + if (WIFEXITED(status)) + panel->exit_code = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + panel->exit_code = -WTERMSIG(status); + } + panel->process_exited.store(true); + panel->reader_running.store(false); +} + +} // namespace + +void open(TerminalPanel& panel) { + if (panel.is_open()) return; + + std::string sh = panel.shell.empty() ? default_shell() : panel.shell; + + // Construir argv. + const char* argv[] = {sh.c_str(), nullptr}; + + // Construir envp: heredar entorno + extras. + // Para simplicidad en v1, pasamos nullptr (hereda el entorno completo) + // y añadimos las variables extra via setenv antes del fork. + // TODO(0132): construir envp completo en v2. + + struct winsize ws; + ws.ws_row = 24; + ws.ws_col = 80; + ws.ws_xpixel = 0; + ws.ws_ypixel = 0; + + int master_fd = -1; + pid_t pid = forkpty(&master_fd, nullptr, nullptr, &ws); + + if (pid < 0) { + fn_log::log_error("terminal_panel: forkpty failed: %s", strerror(errno)); + return; + } + + if (pid == 0) { + // Proceso hijo. + // Aplicar variables de entorno extra. + for (const auto& kv : panel.env) { + const auto eq = kv.find('='); + if (eq != std::string::npos) { + std::string key = kv.substr(0, eq); + std::string val = kv.substr(eq + 1); + ::setenv(key.c_str(), val.c_str(), 1); + } + } + // Cambiar directorio de trabajo si se especificó. + if (!panel.cwd.empty()) { + if (::chdir(panel.cwd.c_str()) != 0) { + // No es fatal — continuar desde el cwd heredado. + } + } + ::execvp(sh.c_str(), const_cast(argv)); + // Si execvp falla, el hijo muere. + _exit(127); + } + + // Proceso padre. + // Poner el fd maestro en modo no-bloqueante. + int flags = ::fcntl(master_fd, F_GETFL, 0); + ::fcntl(master_fd, F_SETFL, flags | O_NONBLOCK); + + panel.master_fd = master_fd; + panel.child_pid = pid; + panel.process_exited.store(false); + panel.reader_running.store(true); + panel.reader_thread = std::thread(reader_thread_fn, &panel); + + fn_log::log_info("terminal_panel: opened shell '%s' pid=%d", sh.c_str(), pid); +} + +void send(TerminalPanel& panel, const std::string& text) { + if (!panel.is_open() || panel.readonly) return; + if (text.empty()) return; + const char* p = text.c_str(); + ssize_t rem = static_cast(text.size()); + while (rem > 0) { + ssize_t n = ::write(panel.master_fd, p, static_cast(rem)); + if (n <= 0) { + if (errno == EINTR) continue; + fn_log::log_error("terminal_panel: write to pty failed: %s", strerror(errno)); + break; + } + p += n; + rem -= n; + } +} + +void close(TerminalPanel& panel) { + // Señalar al reader thread que pare. + panel.reader_running.store(false); + + // Cerrar el fd maestro del PTY; esto hace que el hijo reciba HUP. + if (panel.master_fd >= 0) { + ::close(panel.master_fd); + panel.master_fd = -1; + } + + // Matar al hijo si sigue vivo. + if (panel.child_pid > 0) { + ::kill(panel.child_pid, SIGTERM); + int status = 0; + // Esperar hasta 200 ms; si no terminó, SIGKILL. + for (int i = 0; i < 20; i++) { + if (::waitpid(panel.child_pid, &status, WNOHANG) > 0) break; + usleep(10000); + } + ::kill(panel.child_pid, SIGKILL); + ::waitpid(panel.child_pid, &status, 0); + panel.child_pid = -1; + } + + // Esperar al reader thread. + if (panel.reader_thread.joinable()) panel.reader_thread.join(); + + fn_log::log_info("terminal_panel: closed"); +} + +} // namespace fn_term + +#endif // !_WIN32 diff --git a/cpp/functions/viz/terminal_panel/terminal_panel_windows.cpp b/cpp/functions/viz/terminal_panel/terminal_panel_windows.cpp new file mode 100644 index 00000000..e6f09dd1 --- /dev/null +++ b/cpp/functions/viz/terminal_panel/terminal_panel_windows.cpp @@ -0,0 +1,244 @@ +// terminal_panel_windows.cpp — backend ConPTY para Windows. +// Compilado solo en plataformas Windows (_WIN32). +// +// Implementacion: CreatePseudoConsole (ConPTY, Windows 10 v1809+) + +// CreateProcess + ReadFile en thread de lectura. +// +// Si ConPTY no está disponible (Windows < 10 v1809), cae a un stub que +// reporta error y deja is_open() == false. +// +// TODO(0132): fallback CreatePipe sin PTY para Windows < v1809. + +#ifdef _WIN32 + +#include "viz/terminal_panel/terminal_panel.h" +#include "core/logger.h" + +// Incluir Windows.h con defines minimos para evitar conflictos con ImGui. +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +// ConPTY: disponible en Windows SDK >= 17763 (v1809). +// Si el SDK no tiene ConPTY, definimos stubs minimos para que compile. +#if defined(NTDDI_WIN10_RS5) && NTDDI_VERSION >= NTDDI_WIN10_RS5 +# define FN_CONPTY_AVAILABLE 1 +# include +# include +#else +# define FN_CONPTY_AVAILABLE 0 + // Stub para evitar errores de compilacion en SDKs viejos. + typedef VOID* HPCON; +#endif + +#include + +namespace fn_term { + +namespace { + +std::string default_shell_windows() { + // Preferir PowerShell si está disponible; fallback a cmd.exe. + char buf[MAX_PATH] = {}; + if (ExpandEnvironmentStringsA("%COMSPEC%", buf, sizeof(buf)) > 0 && buf[0] != '\0') + return buf; + return "cmd.exe"; +} + +#if FN_CONPTY_AVAILABLE + +// Thread de lectura: lee del pipe de salida del ConPTY en bloques. +DWORD WINAPI reader_thread_fn(LPVOID param) { + auto* panel = static_cast(param); + char buf[4096]; + DWORD bytes_read = 0; + while (panel->reader_running.load()) { + BOOL ok = ReadFile(static_cast(panel->pipe_read), + buf, sizeof(buf), &bytes_read, nullptr); + if (ok && bytes_read > 0) { + process_output(*panel, buf, static_cast(bytes_read)); + } else { + DWORD err = GetLastError(); + if (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) break; + if (!ok) { + fn_log::log_error("terminal_panel: ReadFile error %lu", err); + break; + } + } + } + // Recolectar código de salida. + if (panel->proc_handle) { + DWORD exit_code = 0; + GetExitCodeProcess(static_cast(panel->proc_handle), &exit_code); + panel->exit_code = static_cast(exit_code); + } + panel->process_exited.store(true); + panel->reader_running.store(false); + return 0; +} + +#endif // FN_CONPTY_AVAILABLE + +} // namespace + +void open(TerminalPanel& panel) { + if (panel.is_open()) return; + +#if !FN_CONPTY_AVAILABLE + fn_log::log_error("terminal_panel: ConPTY not available on this Windows SDK version"); + // TODO(0132): fallback a CreatePipe sin PTY + return; +#else + std::string sh = panel.shell.empty() ? default_shell_windows() : panel.shell; + + // Crear dos pares de pipes: una para PTY→app (lectura) y otra para app→PTY (escritura). + HANDLE hPipeIn_Read = nullptr; // PTY lee desde aqui (stdin del proceso hijo) + HANDLE hPipeIn_Write = nullptr; // app escribe aqui + HANDLE hPipeOut_Read = nullptr; // app lee desde aqui (stdout del proceso hijo) + HANDLE hPipeOut_Write= nullptr; // PTY escribe aqui + + SECURITY_ATTRIBUTES sa; + sa.nLength = sizeof(SECURITY_ATTRIBUTES); + sa.bInheritHandle = FALSE; + sa.lpSecurityDescriptor = nullptr; + + if (!CreatePipe(&hPipeIn_Read, &hPipeIn_Write, &sa, 0) || + !CreatePipe(&hPipeOut_Read, &hPipeOut_Write, &sa, 0)) { + fn_log::log_error("terminal_panel: CreatePipe failed: %lu", GetLastError()); + return; + } + + // Crear ConPTY. + COORD consoleSize; + consoleSize.X = 80; + consoleSize.Y = 24; + HPCON hPC = nullptr; + HRESULT hr = CreatePseudoConsole(consoleSize, hPipeIn_Read, hPipeOut_Write, 0, &hPC); + if (FAILED(hr)) { + fn_log::log_error("terminal_panel: CreatePseudoConsole failed: hr=0x%08lX", hr); + CloseHandle(hPipeIn_Read); + CloseHandle(hPipeIn_Write); + CloseHandle(hPipeOut_Read); + CloseHandle(hPipeOut_Write); + return; + } + + // Los extremos del ConPTY (hPipeIn_Read + hPipeOut_Write) ya no los necesitamos. + CloseHandle(hPipeIn_Read); + CloseHandle(hPipeOut_Write); + + // Preparar STARTUPINFOEX con el ConPTY. + SIZE_T attrListSize = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attrListSize); + auto* attrList = static_cast( + HeapAlloc(GetProcessHeap(), 0, attrListSize)); + if (!attrList || !InitializeProcThreadAttributeList(attrList, 1, 0, &attrListSize)) { + fn_log::log_error("terminal_panel: InitializeProcThreadAttributeList failed"); + ClosePseudoConsole(hPC); + CloseHandle(hPipeIn_Write); + CloseHandle(hPipeOut_Read); + if (attrList) HeapFree(GetProcessHeap(), 0, attrList); + return; + } + UpdateProcThreadAttribute(attrList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + hPC, sizeof(HPCON), nullptr, nullptr); + + STARTUPINFOEXA siEx = {}; + siEx.StartupInfo.cb = sizeof(STARTUPINFOEXA); + siEx.lpAttributeList = attrList; + + PROCESS_INFORMATION pi = {}; + // cmd es la cadena de comando (mutable, CreateProcessA la modifica en algunos casos). + std::string cmd = sh; + if (!CreateProcessA(nullptr, &cmd[0], nullptr, nullptr, FALSE, + EXTENDED_STARTUPINFO_PRESENT, nullptr, + panel.cwd.empty() ? nullptr : panel.cwd.c_str(), + &siEx.StartupInfo, &pi)) { + fn_log::log_error("terminal_panel: CreateProcess failed: %lu", GetLastError()); + DeleteProcThreadAttributeList(attrList); + HeapFree(GetProcessHeap(), 0, attrList); + ClosePseudoConsole(hPC); + CloseHandle(hPipeIn_Write); + CloseHandle(hPipeOut_Read); + return; + } + + // El thread handle del hijo no lo necesitamos. + CloseHandle(pi.hThread); + + DeleteProcThreadAttributeList(attrList); + HeapFree(GetProcessHeap(), 0, attrList); + + panel.pty_handle = static_cast(hPC); + panel.pipe_read = static_cast(hPipeOut_Read); + panel.pipe_write = static_cast(hPipeIn_Write); + panel.proc_handle = static_cast(pi.hProcess); + panel.process_exited.store(false); + panel.reader_running.store(true); + + // Arrancar el reader thread via CreateThread (evitamos std::thread con WINAPI). + HANDLE hThread = CreateThread(nullptr, 0, reader_thread_fn, &panel, 0, nullptr); + if (!hThread) { + fn_log::log_error("terminal_panel: CreateThread failed: %lu", GetLastError()); + // No fatal — el panel queda en estado parcial; close() limpiará. + } else { + // Convertir el HANDLE a std::thread via native_handle trick no es portable. + // Para integración con std::thread::join(), usamos un wrapper. + // En v1: detachamos el thread y usamos el atomic reader_running como señal. + CloseHandle(hThread); + // TODO(0132): migrar a std::thread para poder join() correctamente. + } + + fn_log::log_info("terminal_panel: opened shell '%s' pid=%lu", + sh.c_str(), static_cast(pi.dwProcessId)); +#endif // FN_CONPTY_AVAILABLE +} + +void send(TerminalPanel& panel, const std::string& text) { +#if !FN_CONPTY_AVAILABLE + (void)panel; (void)text; +#else + if (!panel.is_open() || panel.readonly || text.empty()) return; + DWORD written = 0; + WriteFile(static_cast(panel.pipe_write), + text.c_str(), static_cast(text.size()), &written, nullptr); +#endif +} + +void close(TerminalPanel& panel) { + panel.reader_running.store(false); + +#if FN_CONPTY_AVAILABLE + if (panel.pipe_write) { + CloseHandle(static_cast(panel.pipe_write)); + panel.pipe_write = nullptr; + } + if (panel.pipe_read) { + CloseHandle(static_cast(panel.pipe_read)); + panel.pipe_read = nullptr; + } + if (panel.proc_handle) { + TerminateProcess(static_cast(panel.proc_handle), 0); + WaitForSingleObject(static_cast(panel.proc_handle), 500); + CloseHandle(static_cast(panel.proc_handle)); + panel.proc_handle = nullptr; + } + if (panel.pty_handle) { + ClosePseudoConsole(static_cast(panel.pty_handle)); + panel.pty_handle = nullptr; + } +#endif + + // Esperar al reader thread si está joinable. + if (panel.reader_thread.joinable()) panel.reader_thread.join(); + + fn_log::log_info("terminal_panel: closed (windows)"); +} + +} // namespace fn_term + +#endif // _WIN32 diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 771bbba2..fa55426d 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -316,3 +316,20 @@ add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp add_fn_test(test_sse_client test_sse_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp) + +# --- Issue 0132 — ansi_parser: logica pura, sin ImGui --- +add_fn_test(test_ansi_parser test_ansi_parser.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp) + +# --- Issue 0132 — terminal_panel smoke: spawn real PTY (Linux only) --- +# En Windows: todos los casos se skipean via SKIP(). En Linux necesita -lutil. +# Linkamos fn_framework para obtener logger.cpp (fn_log) + imgui + implot. +add_fn_test(test_terminal_panel_smoke test_terminal_panel_smoke.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_linux.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_windows.cpp) +target_link_libraries(test_terminal_panel_smoke PRIVATE fn_framework) +if(NOT WIN32) + target_link_libraries(test_terminal_panel_smoke PRIVATE util) +endif() diff --git a/cpp/tests/test_ansi_parser.cpp b/cpp/tests/test_ansi_parser.cpp new file mode 100644 index 00000000..2f943421 --- /dev/null +++ b/cpp/tests/test_ansi_parser.cpp @@ -0,0 +1,215 @@ +// test_ansi_parser.cpp — tests unitarios para fn_term::AnsiParser. +// +// Logica pura: no requiere ImGui ni contexto GL. Cubre: +// - SGR: reset, FG color, BG color, bright colors, bold +// - Cursor moves: CUU/CUD/CUF/CUB, CUP +// - ED(2) erase display, EL(2) erase line +// - Texto normal + secuencias mixtas +// - CR, LF, BS + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "core/ansi_parser.h" + +#include +#include + +using namespace fn_term; + +// Helper: parsea una cadena y colecta los eventos. +static std::vector parse(const std::string& s) { + AnsiParser p; + std::vector evs; + p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) { + evs.push_back(ev); + }); + return evs; +} + +// Helper: obtiene estados SGR después de parsear (sin eventos de salida). +struct SgrState { uint8_t fg; uint8_t bg; uint8_t bold; }; +static SgrState parse_sgr(const std::string& s) { + AnsiParser p; + p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {}); + return {p.current_fg(), p.current_bg(), p.current_bold()}; +} + +// --------------------------------------------------------------------------- +// SGR tests +// --------------------------------------------------------------------------- + +TEST_CASE("SGR reset sets default colors", "[ansi_parser][sgr]") { + // Primero ponemos FG rojo, luego reset. + auto st = parse_sgr("\x1b[31m\x1b[0m"); + REQUIRE(st.fg == kColorDefault); + REQUIRE(st.bg == kColorDefault); + REQUIRE(st.bold == 0); +} + +TEST_CASE("SGR fg color 31 sets red", "[ansi_parser][sgr]") { + auto st = parse_sgr("\x1b[31m"); + REQUIRE(st.fg == 1); // rojo = index 1 +} + +TEST_CASE("SGR bg color 44 sets blue background", "[ansi_parser][sgr]") { + auto st = parse_sgr("\x1b[44m"); + REQUIRE(st.bg == 4); // azul = index 4 +} + +TEST_CASE("SGR bright fg 91 sets bright red", "[ansi_parser][sgr]") { + auto st = parse_sgr("\x1b[91m"); + REQUIRE(st.fg == 9); // bright red = index 8+1 = 9 +} + +TEST_CASE("SGR bold sets bold flag", "[ansi_parser][sgr]") { + auto st = parse_sgr("\x1b[1m"); + REQUIRE(st.bold == 1); +} + +TEST_CASE("SGR reset via bare ESC[m", "[ansi_parser][sgr]") { + // ESC [ m sin parametro = reset + auto st = parse_sgr("\x1b[31m\x1b[m"); + REQUIRE(st.fg == kColorDefault); +} + +// --------------------------------------------------------------------------- +// Cursor move tests +// --------------------------------------------------------------------------- + +TEST_CASE("cursor CUU moves up N", "[ansi_parser][cursor]") { + auto evs = parse("\x1b[3A"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::CursorMove); + REQUIRE(evs[0].cursor_rel.dir == CursorDir::Up); + REQUIRE(evs[0].cursor_rel.n == 3); +} + +TEST_CASE("cursor CUF moves forward N", "[ansi_parser][cursor]") { + auto evs = parse("\x1b[5C"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::CursorMove); + REQUIRE(evs[0].cursor_rel.dir == CursorDir::Forward); + REQUIRE(evs[0].cursor_rel.n == 5); +} + +TEST_CASE("cursor CUB moves back 1 when no param", "[ansi_parser][cursor]") { + auto evs = parse("\x1b[D"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::CursorMove); + REQUIRE(evs[0].cursor_rel.dir == CursorDir::Back); + REQUIRE(evs[0].cursor_rel.n == 1); +} + +TEST_CASE("cursor CUP absolute position", "[ansi_parser][cursor]") { + // ESC[5;10H → row=4, col=9 (0-based) + auto evs = parse("\x1b[5;10H"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute); + REQUIRE(evs[0].cursor_abs.row == 4); + REQUIRE(evs[0].cursor_abs.col == 9); +} + +TEST_CASE("cursor CUP default params (ESC[H) = origin", "[ansi_parser][cursor]") { + auto evs = parse("\x1b[H"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute); + REQUIRE(evs[0].cursor_abs.row == 0); + REQUIRE(evs[0].cursor_abs.col == 0); +} + +// --------------------------------------------------------------------------- +// Erase tests +// --------------------------------------------------------------------------- + +TEST_CASE("erase display ED 2", "[ansi_parser][erase]") { + auto evs = parse("\x1b[2J"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::EraseDisplay); +} + +TEST_CASE("erase line EL 2", "[ansi_parser][erase]") { + auto evs = parse("\x1b[2K"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::EraseLine); +} + +// --------------------------------------------------------------------------- +// Control chars +// --------------------------------------------------------------------------- + +TEST_CASE("newline and carriage return", "[ansi_parser][control]") { + auto evs = parse("\r\n"); + REQUIRE(evs.size() == 2); + REQUIRE(evs[0].type == AnsiEventType::CarriageReturn); + REQUIRE(evs[1].type == AnsiEventType::Newline); +} + +TEST_CASE("backspace emits Backspace event", "[ansi_parser][control]") { + auto evs = parse("\x08"); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].type == AnsiEventType::Backspace); +} + +// --------------------------------------------------------------------------- +// Text + mixed sequences +// --------------------------------------------------------------------------- + +TEST_CASE("plain text emits Char events", "[ansi_parser][text]") { + auto evs = parse("hi"); + REQUIRE(evs.size() == 2); + REQUIRE(evs[0].type == AnsiEventType::Char); + REQUIRE(evs[0].cell.ch == U'h'); + REQUIRE(evs[1].cell.ch == U'i'); +} + +TEST_CASE("mixed text and SGR sequence", "[ansi_parser][mixed]") { + // "A" con FG rojo, luego reset, luego "B". + auto evs = parse("\x1b[31mA\x1b[0mB"); + // Debemos tener exactamente 2 eventos Char: A (fg=1) y B (fg=default). + REQUIRE(evs.size() == 2); + REQUIRE(evs[0].type == AnsiEventType::Char); + REQUIRE(evs[0].cell.ch == U'A'); + REQUIRE(evs[0].cell.fg == 1); // rojo + REQUIRE(evs[1].type == AnsiEventType::Char); + REQUIRE(evs[1].cell.ch == U'B'); + REQUIRE(evs[1].cell.fg == kColorDefault); +} + +TEST_CASE("char inherits current SGR attrs", "[ansi_parser][sgr]") { + AnsiParser p; + std::vector evs; + // Poner BG azul, luego emitir texto. + std::string s = "\x1b[44mX"; + p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) { evs.push_back(ev); }); + REQUIRE(evs.size() == 1); + REQUIRE(evs[0].cell.ch == U'X'); + REQUIRE(evs[0].cell.bg == 4); // azul +} + +TEST_CASE("unknown CSI final byte ignored silently", "[ansi_parser][robustness]") { + // ESC [ Z es desconocido — no debe emitir nada ni crashear. + auto evs = parse("a\x1b[Zb"); + REQUIRE(evs.size() == 2); + REQUIRE(evs[0].cell.ch == U'a'); + REQUIRE(evs[1].cell.ch == U'b'); +} + +TEST_CASE("incomplete escape at end of buffer", "[ansi_parser][robustness]") { + // Buffer termina a mitad de una secuencia — no debe crashear. + AnsiParser p; + std::string s1 = "\x1b[3"; + std::string s2 = "1m"; + p.feed(s1.c_str(), s1.size(), [](const AnsiEvent&) {}); + p.feed(s2.c_str(), s2.size(), [](const AnsiEvent&) {}); + REQUIRE(p.current_fg() == 1); // FG rojo aplicado correctamente +} + +TEST_CASE("reset() clears state", "[ansi_parser][reset]") { + AnsiParser p; + std::string s = "\x1b[31m"; // FG rojo + p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {}); + REQUIRE(p.current_fg() == 1); + p.reset(); + REQUIRE(p.current_fg() == kColorDefault); +} diff --git a/cpp/tests/test_terminal_panel_smoke.cpp b/cpp/tests/test_terminal_panel_smoke.cpp new file mode 100644 index 00000000..d21340f0 --- /dev/null +++ b/cpp/tests/test_terminal_panel_smoke.cpp @@ -0,0 +1,110 @@ +// test_terminal_panel_smoke.cpp — smoke test para terminal_panel. +// +// Prueba real del PTY en Linux: spawn "echo hello && exit 0", +// espera output, verifica que el scrollback contiene "hello". +// +// En Windows: test skipped (ConPTY require DISPLAY y proceso vivo — CI). +// En Linux sin forkpty: verifica que el build es correcto al menos. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "viz/terminal_panel/terminal_panel.h" + +#include +#include +#include + +#ifdef _WIN32 +// En Windows en CI, skipeamos el smoke del proceso real. +TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") { + SKIP("Smoke PTY test skipped on Windows CI"); +} +#else + +// Helper: concatena todas las celdas del scrollback como texto plano. +static std::string scrollback_text(fn_term::TerminalPanel& p) { + std::lock_guard lk(p.buf_mutex); + std::string result; + for (const auto& line : p.lines) { + for (const auto& cell : line) { + if (cell.ch >= 0x20 && cell.ch < 0x7F) + result += static_cast(cell.ch); + } + result += '\n'; + } + return result; +} + +TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") { + fn_term::TerminalPanel term; + term.shell = "/bin/bash"; + term.scrollback_lines = 100; + + fn_term::open(term); + REQUIRE(term.is_open()); + + // Enviar el comando y esperar a que el proceso salga. + fn_term::send(term, "echo hello && exit 0\n"); + + // Esperar máximo 2 segundos a que el proceso termine. + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!term.process_exited.load() + && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + + // Dar 100ms adicionales para que el reader thread procese el último output. + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::string text = scrollback_text(term); + fn_term::close(term); + + INFO("scrollback: " << text); + REQUIRE(text.find("hello") != std::string::npos); +} + +TEST_CASE("smoke: process exits cleanly", "[terminal_panel][smoke]") { + fn_term::TerminalPanel term; + term.shell = "/bin/bash"; + term.scrollback_lines = 50; + + fn_term::open(term); + REQUIRE(term.is_open()); + + fn_term::send(term, "exit 0\n"); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!term.process_exited.load() + && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + + REQUIRE(term.process_exited.load()); + REQUIRE(term.exit_code == 0); + + fn_term::close(term); +} + +TEST_CASE("smoke: readonly panel ignores send", "[terminal_panel][smoke]") { + fn_term::TerminalPanel term; + term.shell = "/bin/bash"; + term.readonly = true; + term.scrollback_lines = 50; + + fn_term::open(term); + REQUIRE(term.is_open()); + + // send() no debe hacer nada (readonly). + fn_term::send(term, "echo should_not_appear\n"); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + std::string text = scrollback_text(term); + fn_term::close(term); + + // "should_not_appear" no debería estar en el scrollback porque send es no-op. + INFO("scrollback: " << text); + REQUIRE(text.find("should_not_appear") == std::string::npos); +} + +#endif // !_WIN32