feat(0132): cpp terminal_panel module + ansi_parser
Nuevo modulo reutilizable terminal_panel (fn_term) para ImGui: Sub-fn ansi_parser_cpp_core (cpp/functions/core/): - Parser ANSI/VT100 byte-a-byte sin heap allocs por evento - SGR colores FG/BG 16-color + bold + reset - Cursor moves CUU/CUD/CUF/CUB + CUP absoluto - Erase ED(2)/EL(2), CR/LF/BS - Statemachine 4 estados, thread-unsafe por diseno - 21 tests unitarios (57 assertions), todos pasan terminal_panel_cpp_viz (cpp/functions/viz/terminal_panel/): - terminal_panel.cpp: render ImGui + process_output con list clipper - terminal_panel_linux.cpp: forkpty + reader thread no-blocking - terminal_panel_windows.cpp: ConPTY CreatePseudoConsole (SDK >= 17763) - Scrollback circular configurable (default 5000 lineas) - Toolbar: clear, copy, reset, scroll-lock + status indicator - readonly mode: sin input box, send() es no-op - uses_functions: ansi_parser_cpp_core, logger_cpp_core Tests: - test_ansi_parser.cpp: 21 test cases, 57 assertions (PASS) - test_terminal_panel_smoke.cpp: 3 test cases (PASS: spawn echo hello, process exits cleanly, readonly ignores send) CMake: - cpp/tests/CMakeLists.txt: add test_ansi_parser + test_terminal_panel_smoke - primitives_gallery (sub-repo): ver commit separado en apps/primitives_gallery Pendiente (anti-scope v1): - Windows ConPTY: stub funcional que compila; join() del reader thread via std::thread no implementado (usa CreateThread detached) - ANSI 256/24-bit color, italics, Unicode wide - Curses pesados (vim, htop, top) — cursor visible basic solo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void(const AnsiEvent&)>& cb) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
process_byte(static_cast<unsigned char>(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<void(const AnsiEvent&)>& /*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<uint8_t>(code - 30);
|
||||
} else if (code == 39) {
|
||||
cur_fg_ = kColorDefault;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 40);
|
||||
} else if (code == 49) {
|
||||
cur_bg_ = kColorDefault;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
cur_fg_ = static_cast<uint8_t>(code - 90 + 8);
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
cur_bg_ = static_cast<uint8_t>(code - 100 + 8);
|
||||
}
|
||||
// Otros códigos ignorados silenciosamente (v1 anti-scope).
|
||||
}
|
||||
}
|
||||
|
||||
void AnsiParser::dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& 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<void(const AnsiEvent&)>& 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<char32_t>(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
|
||||
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
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<void(const AnsiEvent&)>& 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<void(const AnsiEvent&)>& cb);
|
||||
void flush_param();
|
||||
void dispatch_csi(unsigned char final_byte,
|
||||
const std::function<void(const AnsiEvent&)>& cb);
|
||||
void apply_sgr(const std::function<void(const AnsiEvent&)>& cb);
|
||||
};
|
||||
|
||||
} // namespace fn_term
|
||||
@@ -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<void(const fn_term::AnsiEvent&)>& 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<char>(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.
|
||||
@@ -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 <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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<ImU32>(kPalette16[idx]);
|
||||
}
|
||||
|
||||
// Renderiza una línea del scrollback con colores.
|
||||
// Toma la línea como vector<AnsiCell> 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<char>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<char>(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<std::mutex> 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
|
||||
@@ -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 <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fn_term {
|
||||
|
||||
// Una linea del scrollback: vector de celdas ya parseadas.
|
||||
using TermLine = std::vector<AnsiCell>;
|
||||
|
||||
// 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<std::string> 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<bool> reader_running{false};
|
||||
|
||||
// Scrollback buffer (protegido por mutex)
|
||||
mutable std::mutex buf_mutex;
|
||||
std::vector<TermLine> 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<bool> 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
|
||||
@@ -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.
|
||||
@@ -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 <cerrno>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <pty.h> // 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<size_t>(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<char* const*>(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<ssize_t>(text.size());
|
||||
while (rem > 0) {
|
||||
ssize_t n = ::write(panel.master_fd, p, static_cast<size_t>(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
|
||||
@@ -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 <windows.h>
|
||||
|
||||
// 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 <consoleapi3.h>
|
||||
# include <processthreadsapi.h>
|
||||
#else
|
||||
# define FN_CONPTY_AVAILABLE 0
|
||||
// Stub para evitar errores de compilacion en SDKs viejos.
|
||||
typedef VOID* HPCON;
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
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<TerminalPanel*>(param);
|
||||
char buf[4096];
|
||||
DWORD bytes_read = 0;
|
||||
while (panel->reader_running.load()) {
|
||||
BOOL ok = ReadFile(static_cast<HANDLE>(panel->pipe_read),
|
||||
buf, sizeof(buf), &bytes_read, nullptr);
|
||||
if (ok && bytes_read > 0) {
|
||||
process_output(*panel, buf, static_cast<size_t>(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<HANDLE>(panel->proc_handle), &exit_code);
|
||||
panel->exit_code = static_cast<int>(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<LPPROC_THREAD_ATTRIBUTE_LIST>(
|
||||
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<void*>(hPC);
|
||||
panel.pipe_read = static_cast<void*>(hPipeOut_Read);
|
||||
panel.pipe_write = static_cast<void*>(hPipeIn_Write);
|
||||
panel.proc_handle = static_cast<void*>(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<unsigned long>(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<HANDLE>(panel.pipe_write),
|
||||
text.c_str(), static_cast<DWORD>(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<HANDLE>(panel.pipe_write));
|
||||
panel.pipe_write = nullptr;
|
||||
}
|
||||
if (panel.pipe_read) {
|
||||
CloseHandle(static_cast<HANDLE>(panel.pipe_read));
|
||||
panel.pipe_read = nullptr;
|
||||
}
|
||||
if (panel.proc_handle) {
|
||||
TerminateProcess(static_cast<HANDLE>(panel.proc_handle), 0);
|
||||
WaitForSingleObject(static_cast<HANDLE>(panel.proc_handle), 500);
|
||||
CloseHandle(static_cast<HANDLE>(panel.proc_handle));
|
||||
panel.proc_handle = nullptr;
|
||||
}
|
||||
if (panel.pty_handle) {
|
||||
ClosePseudoConsole(static_cast<HPCON>(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
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_term;
|
||||
|
||||
// Helper: parsea una cadena y colecta los eventos.
|
||||
static std::vector<AnsiEvent> parse(const std::string& s) {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> 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<AnsiEvent> 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);
|
||||
}
|
||||
@@ -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 <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#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<std::mutex> 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<char>(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
|
||||
Reference in New Issue
Block a user