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:
2026-05-22 23:35:11 +02:00
parent 3791afa216
commit 07252c0172
11 changed files with 1718 additions and 0 deletions
@@ -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