Files
navegator_dashboard/agent.cpp
T
egutierrez cc1b324ffb feat(navegator_dashboard): v1+v2 — CDP HTTP client, real Tabs panel, full Network panel
CDP HTTP client (cdp_http.h/cpp): WinSock raw + crude_json. GET /json/version,
/json (list tabs), PUT /json/new (Chrome 137+), GET /json/activate/{id},
/json/close/{id}.

CDP WebSocket client (cdp_ws.h/cpp): RFC 6455 handshake + framing manual,
masked client frames, async dispatcher con queue + wait_response. Soporta
fragmentacion (FIN=0 + continuation), ping/pong, close frame. Stats bytes
in/out + frames in.

Cross-panel session (session_state.h/cpp): selected_browser_port +
selected_tab_id. Cambiar tab cierra/abre NetworkSession.

Tabs panel: real. List + filtro titulo/URL + Refresh + New tab + Focus +
Close + Select (alimenta Network panel).

Network panel: DevTools-like.
  - Tabla: Name | Status (color) | Method | Type | Initiator | Size | Time | Waterfall
  - Filtros: text + invert + chips (Doc/CSS/JS/XHR/Img/Media/Font/WS/Other) + All toggle
  - Toggles: Preserve log, Disable cache, Hide data:, Only failed, Pause/Resume
  - Detalle por request: Headers (general + req + res) | Payload | Response (lazy
    Network.getResponseBody) | Cookies | Timing | WS Messages (frames in/out)
  - Right-click row: Copy URL / Copy as cURL / Copy as fetch
  - Status bar: N requests | bytes transferred | resources | Finish | DCL | Load
  - Export HAR 1.2 a archivo junto al exe

NetworkSession parsea Network.requestWillBeSent + ExtraInfo, responseReceived
+ ExtraInfo, dataReceived, loadingFinished, loadingFailed, webSocketCreated,
webSocketFrameSent/Received/Closed, Page.frameNavigated (autoclear si !preserve),
domContentEventFired, loadEventFired.

API local extendida (local_api.cpp):
  - GET  /browser/{port}/version
  - GET  /browser/{port}/tabs
  - POST /browser/{port}/tab/new?url=
  - POST /browser/{port}/tab/{id}/focus
  - POST /browser/{port}/tab/{id}/close
  - GET  /browser/{port}/har  (HAR 1.2 export de la sesion activa)

Build:
  - CMakeLists.txt linka imgui_node_editor solo para reusar crude_json (sin
    codigo de node-editor en runtime).
  - 15 MB exe Windows. Cross-compile mingw-w64 OK.

app.md: bump version 0.2.0 -> 0.3.0, panels matrix actualizado, e2e_checks
añade api_health + api_browsers (warning).

Issue 0002 (sub-issue del roadmap navegator_dashboard 0001).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:51:23 +02:00

1290 lines
48 KiB
C++

#include "agent.h"
#include "app_base.h"
#include "imgui.h"
#include "core/icons_tabler.h"
#include "core/selectable_text.h"
#include "core/logger.h"
// sqlite3 not needed in navegator (counter stub returns 0)
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <deque>
#include <filesystem>
#include <mutex>
#include <random>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace app_agent {
// ----------------------------------------------------------------------------
// Logger con tags
// ----------------------------------------------------------------------------
namespace {
std::mutex g_log_mu;
std::string g_log_path;
std::string iso_now_ms() {
using namespace std::chrono;
auto now = system_clock::now();
auto t = system_clock::to_time_t(now);
auto ms = duration_cast<milliseconds>(now.time_since_epoch()).count() % 1000;
char buf[40];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t));
char out[64];
std::snprintf(out, sizeof(out), "%s.%03lldZ", buf, (long long)ms);
return out;
}
} // namespace
void chat_log(const char* tag, const char* fmt, ...) {
if (!tag) tag = "chat";
char body[2048];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(body, sizeof(body), fmt, ap);
va_end(ap);
std::string ts = iso_now_ms();
std::string ln = ts + " [chat:" + tag + "] " + body + "\n";
{
std::lock_guard<std::mutex> lk(g_log_mu);
std::fputs(ln.c_str(), stderr);
std::fflush(stderr);
if (!g_log_path.empty()) {
FILE* f = std::fopen(g_log_path.c_str(), "ab");
if (f) {
std::fputs(ln.c_str(), f);
std::fclose(f);
}
}
}
// Mirror al logger global (ventana Settings → Logs...) — asi el usuario
// puede ver los eventos de chat sin abrir chat.log en disco.
if (std::strcmp(tag, "error") == 0) {
fn_log::log_error("[chat:%s] %s", tag, body);
} else {
fn_log::log_info ("[chat:%s] %s", tag, body);
}
}
const char* chat_log_path() {
return g_log_path.c_str();
}
namespace {
// ----------------------------------------------------------------------------
// Pequeño extractor JSON (sin dependencias). Solo lo justo para sacar el
// valor string asociado a una clave dentro de un objeto JSON. Maneja escapes
// basicos y devuelve "" si no encuentra la clave.
// ----------------------------------------------------------------------------
std::string json_unescape(const char* s, size_t n) {
std::string out;
out.reserve(n);
for (size_t i = 0; i < n; ++i) {
char c = s[i];
if (c == '\\' && i + 1 < n) {
char nx = s[++i];
switch (nx) {
case 'n': out += '\n'; break;
case 't': out += '\t'; break;
case 'r': out += '\r'; break;
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'u': {
if (i + 4 < n) {
unsigned cp = 0;
for (int k = 0; k < 4; ++k) {
char h = s[i + 1 + k];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= (h - '0');
else if (h >= 'a' && h <= 'f') cp |= (h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= (h - 'A' + 10);
}
i += 4;
// utf-8 encode
if (cp < 0x80) {
out += (char)cp;
} else if (cp < 0x800) {
out += (char)(0xC0 | (cp >> 6));
out += (char)(0x80 | (cp & 0x3F));
} else {
out += (char)(0xE0 | (cp >> 12));
out += (char)(0x80 | ((cp >> 6) & 0x3F));
out += (char)(0x80 | (cp & 0x3F));
}
}
break;
}
default: out += nx; break;
}
} else {
out += c;
}
}
return out;
}
// Devuelve el offset del valor (excluyendo comillas si string) o -1 si no
// existe. Solo busca claves a nivel "shallow" — no recursivo. Suficiente
// para el formato de stream-json de claude -p.
bool json_find_string(const std::string& obj, const char* key, std::string* out) {
std::string pat = "\"";
pat += key;
pat += "\"";
size_t pos = obj.find(pat);
while (pos != std::string::npos) {
size_t i = pos + pat.size();
while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i;
if (i >= obj.size() || obj[i] != ':') {
pos = obj.find(pat, pos + 1);
continue;
}
++i;
while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t')) ++i;
if (i >= obj.size() || obj[i] != '"') return false; // no es string
++i;
size_t start = i;
while (i < obj.size()) {
if (obj[i] == '\\' && i + 1 < obj.size()) { i += 2; continue; }
if (obj[i] == '"') break;
++i;
}
if (i >= obj.size()) return false;
*out = json_unescape(obj.data() + start, i - start);
return true;
}
return false;
}
std::string make_uuid_v4() {
std::random_device rd;
std::mt19937_64 gen(rd());
std::uniform_int_distribution<uint64_t> dis;
uint64_t a = dis(gen), b = dis(gen);
char buf[40];
std::snprintf(buf, sizeof(buf),
"%08x-%04x-4%03x-%04x-%012llx",
(unsigned)(a & 0xFFFFFFFF),
(unsigned)((a >> 32) & 0xFFFF),
(unsigned)((a >> 48) & 0x0FFF),
(unsigned)(0x8000 | (b & 0x3FFF)),
(unsigned long long)((b >> 16) & 0xFFFFFFFFFFFFULL));
return buf;
}
// ----------------------------------------------------------------------------
// Estado del chat
// ----------------------------------------------------------------------------
struct ChatMessage {
enum Kind { USER, ASSISTANT, TOOL_USE, TOOL_RESULT, SYSTEM, ERROR_MSG };
Kind kind;
std::string text; // user/assistant: texto. tool_*: descripcion.
std::string tool_name; // para TOOL_USE
std::string tool_input; // resumen del input
};
struct State {
std::string ops_db;
std::string app_db;
std::string app_dir;
std::string claude_cmd; // "claude" o "wsl"
std::vector<std::string> claude_pre_args; // ["claude"] si wsl, vacio si nativo
std::string mcp_config_path; // path Linux del mcp.json para --mcp-config
bool ready = false;
bool first_turn_done = false;
std::string session_id; // uuid
std::mutex mu;
std::vector<ChatMessage> history;
std::atomic<bool> busy{false};
std::thread worker;
char input_buf[8192] = {};
bool scroll_to_bottom = false;
bool show_raw = false;
std::vector<std::string> raw_lines;
};
State* g_st = nullptr;
// ----------------------------------------------------------------------------
// Detectar disponibilidad de claude
// ----------------------------------------------------------------------------
// Captura stdout+stderr de un comando corto y devuelve {exit_code, output}.
// Si falla creando el proceso, devuelve {-1, ""}.
struct ProbeResult { int rc; std::string out; };
#ifdef _WIN32
ProbeResult probe_capture(const std::string& cmdline) {
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
HANDLE r=nullptr, w=nullptr;
if (!CreatePipe(&r, &w, &sa, 0)) return {-1, ""};
SetHandleInformation(r, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
si.hStdOutput = w;
si.hStdError = w;
PROCESS_INFORMATION pi{};
std::string mutable_cmd = cmdline;
BOOL ok = CreateProcessA(
nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi);
CloseHandle(w);
if (!ok) {
CloseHandle(r);
DWORD err = GetLastError();
chat_log("detect", "CreateProcess fallo (err=%lu) para: %s",
(unsigned long)err, cmdline.c_str());
return {-1, ""};
}
std::string out;
char buf[1024];
DWORD n = 0;
while (ReadFile(r, buf, sizeof(buf), &n, nullptr) && n > 0) {
out.append(buf, n);
}
CloseHandle(r);
DWORD code = 0;
WaitForSingleObject(pi.hProcess, 5000); // 5s timeout
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return {(int)code, out};
}
#else
ProbeResult probe_capture(const std::string& cmdline) {
std::string c = cmdline + " 2>&1";
FILE* p = popen(c.c_str(), "r");
if (!p) return {-1, ""};
std::string out;
char buf[1024];
size_t n;
while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) {
out.append(buf, n);
}
int rc = pclose(p);
if (rc == -1) return {-1, out};
if (WIFEXITED(rc)) rc = WEXITSTATUS(rc);
return {rc, out};
}
#endif
void detect_claude() {
if (!g_st) return;
#ifdef _WIN32
// Probe 1: wsl --status (verifica que WSL existe).
auto p_status = probe_capture("wsl.exe --status");
chat_log("detect", "wsl.exe --status -> rc=%d, out=%.200s",
p_status.rc, p_status.out.c_str());
// Probe 2: bash login shell carga ~/.profile asi que claude (en
// ~/.local/bin/) entra en PATH. Capturamos la ruta absoluta para usarla
// luego con `wsl.exe --exec <path>` y bypassear el problema de quoting
// de wsl.exe en modo `--`.
auto p_path = probe_capture(
"wsl.exe -- bash -lc \"command -v claude\"");
chat_log("detect", "wsl bash -lc 'command -v claude' -> rc=%d, out=%.200s",
p_path.rc, p_path.out.c_str());
// Trim del path resultante (puede tener \r\n)
std::string claude_path = p_path.out;
while (!claude_path.empty() &&
(claude_path.back() == '\n' || claude_path.back() == '\r' ||
claude_path.back() == ' ' || claude_path.back() == '\t')) {
claude_path.pop_back();
}
bool wsl_works = (p_status.rc == 0);
bool claude_works = (p_path.rc == 0) && !claude_path.empty();
if (claude_works) {
g_st->claude_cmd = "wsl.exe";
// --exec NO usa shell, evita el problema de wsl.exe que reconcatena
// y rompe el quoting de los args (perdiamos las comillas de
// --append-system-prompt y de "$@" → exit 127). Path absoluto al
// binario claude detectado en el probe anterior.
g_st->claude_pre_args = {"--exec", claude_path};
g_st->ready = true;
chat_log("detect", "OK — claude en %s, usaremos wsl.exe --exec",
claude_path.c_str());
} else if (wsl_works) {
// wsl funciona pero no localizamos claude. Como fallback intentamos
// un path estandar conocido — el primer send dira si funciono.
g_st->claude_cmd = "wsl.exe";
g_st->claude_pre_args = {"--exec", "/home/lucas/.local/bin/claude"};
g_st->ready = true;
chat_log("detect", "wsl OK pero no localizamos claude — "
"fallback a /home/lucas/.local/bin/claude");
} else {
g_st->ready = false;
chat_log("detect", "wsl --status fallo — panel Chat deshabilitado");
}
#else
auto p = probe_capture("command -v claude");
chat_log("detect", "command -v claude -> rc=%d, out=%.200s",
p.rc, p.out.c_str());
if (p.rc == 0) {
g_st->claude_cmd = "claude";
g_st->claude_pre_args = {};
g_st->ready = true;
auto v = probe_capture("claude --version");
chat_log("detect", "claude --version -> rc=%d, out=%.200s",
v.rc, v.out.c_str());
} else {
g_st->ready = false;
chat_log("detect", "claude no esta en PATH — panel deshabilitado");
}
#endif
}
// ----------------------------------------------------------------------------
// Subprocess: enviar mensaje y leer stream-json hasta EOF
// ----------------------------------------------------------------------------
#ifndef _WIN32
// Lanza claude -p, escribe `prompt` en stdin, lee stdout linea a linea.
// Cada linea bien-formada (JSON object) se pasa a `on_line`. Devuelve el
// exit code, o -1 si fork/exec falla.
int run_claude_streaming(
const std::vector<std::string>& argv,
const std::string& prompt,
void (*on_line)(const std::string&))
{
int in_pipe[2], out_pipe[2];
if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) return -1;
pid_t pid = fork();
if (pid < 0) {
chat_log("error", "fork() fallo: errno=%d (%s)", errno, std::strerror(errno));
close(in_pipe[0]); close(in_pipe[1]);
close(out_pipe[0]); close(out_pipe[1]);
return -1;
}
if (pid == 0) {
// child
dup2(in_pipe[0], STDIN_FILENO);
dup2(out_pipe[1], STDOUT_FILENO);
// stderr al stderr del padre (debugging visible en consola)
close(in_pipe[0]); close(in_pipe[1]);
close(out_pipe[0]); close(out_pipe[1]);
std::vector<char*> args;
for (auto& a : argv) args.push_back(const_cast<char*>(a.c_str()));
args.push_back(nullptr);
execvp(args[0], args.data());
_exit(127);
}
// parent
close(in_pipe[0]);
close(out_pipe[1]);
// escribe prompt y cierra
if (!prompt.empty()) {
ssize_t n = write(in_pipe[1], prompt.data(), prompt.size());
chat_log("io", "stdin write %zd/%zu bytes",
(ssize_t)n, prompt.size());
}
close(in_pipe[1]);
size_t total_in = 0;
int lines = 0;
std::string buf;
char chunk[4096];
for (;;) {
ssize_t r = read(out_pipe[0], chunk, sizeof(chunk));
if (r <= 0) break;
total_in += (size_t)r;
buf.append(chunk, (size_t)r);
size_t nl;
while ((nl = buf.find('\n')) != std::string::npos) {
std::string line = buf.substr(0, nl);
buf.erase(0, nl + 1);
if (!line.empty()) { on_line(line); ++lines; }
}
}
if (!buf.empty()) { on_line(buf); ++lines; }
chat_log("io", "stdout EOF — total=%zu bytes, %d lineas", total_in, lines);
close(out_pipe[0]);
int status = 0;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) return WEXITSTATUS(status);
if (WIFSIGNALED(status)) {
chat_log("error", "subprocess signaled %d", WTERMSIG(status));
}
return -1;
}
#else // _WIN32
int run_claude_streaming(
const std::vector<std::string>& argv,
const std::string& prompt,
void (*on_line)(const std::string&))
{
// Construye command line con escape minimo
std::string cmd;
for (size_t i = 0; i < argv.size(); ++i) {
if (i) cmd += ' ';
const std::string& a = argv[i];
bool need_q = a.empty() || a.find_first_of(" \t\"") != std::string::npos;
if (need_q) {
cmd += '"';
for (char c : a) {
if (c == '"') cmd += "\\\"";
else cmd += c;
}
cmd += '"';
} else {
cmd += a;
}
}
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
HANDLE in_r=nullptr, in_w=nullptr, out_r=nullptr, out_w=nullptr;
if (!CreatePipe(&in_r, &in_w, &sa, 0)) return -1;
SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0);
if (!CreatePipe(&out_r, &out_w, &sa, 0)) {
CloseHandle(in_r); CloseHandle(in_w);
return -1;
}
SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = in_r;
si.hStdOutput = out_w;
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
PROCESS_INFORMATION pi{};
chat_log("spawn", "CreateProcess cmdline=%s", cmd.c_str());
BOOL ok = CreateProcessA(
nullptr, cmd.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi);
CloseHandle(in_r);
CloseHandle(out_w);
if (!ok) {
DWORD err = GetLastError();
chat_log("error", "CreateProcess fallo, GetLastError=%lu",
(unsigned long)err);
CloseHandle(in_w); CloseHandle(out_r);
return -1;
}
if (!prompt.empty()) {
DWORD wn = 0;
WriteFile(in_w, prompt.data(), (DWORD)prompt.size(), &wn, nullptr);
chat_log("io", "stdin write %lu/%zu bytes",
(unsigned long)wn, prompt.size());
}
CloseHandle(in_w);
size_t total_in = 0;
int lines = 0;
std::string buf;
char chunk[4096];
for (;;) {
DWORD rn = 0;
BOOL r = ReadFile(out_r, chunk, sizeof(chunk), &rn, nullptr);
if (!r || rn == 0) break;
total_in += (size_t)rn;
buf.append(chunk, (size_t)rn);
size_t nl;
while ((nl = buf.find('\n')) != std::string::npos) {
std::string line = buf.substr(0, nl);
// strip trailing CR
if (!line.empty() && line.back() == '\r') line.pop_back();
buf.erase(0, nl + 1);
if (!line.empty()) { on_line(line); ++lines; }
}
}
if (!buf.empty()) { on_line(buf); ++lines; }
chat_log("io", "stdout EOF — total=%zu bytes, %d lineas",
total_in, lines);
CloseHandle(out_r);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return (int)code;
}
#endif
// ----------------------------------------------------------------------------
// Procesado de eventos stream-json
// ----------------------------------------------------------------------------
void on_stream_line(const std::string& line) {
if (!g_st) return;
// Filtrar lineas no-JSON (logs spurios) — empiezan con '{'
if (line.empty() || line[0] != '{') return;
{
std::lock_guard<std::mutex> lk(g_st->mu);
if (g_st->raw_lines.size() > 500) {
g_st->raw_lines.erase(g_st->raw_lines.begin(),
g_st->raw_lines.begin() + 200);
}
g_st->raw_lines.push_back(line);
}
std::string type;
if (!json_find_string(line, "type", &type)) return;
// System init: capturar session_id si todavia no lo tenemos
if (type == "system") {
std::string sid, model;
json_find_string(line, "session_id", &sid);
json_find_string(line, "model", &model);
chat_log("parse", "system init: session=%s model=%s",
sid.c_str(), model.c_str());
if (!sid.empty()) {
std::lock_guard<std::mutex> lk(g_st->mu);
if (g_st->session_id.empty()) g_st->session_id = sid;
}
return;
}
// Assistant message: extraer text content
if (type == "assistant") {
// El campo "text" puede aparecer dentro del array content[].
// Buscamos todos los "text":"..." que no sean del campo de tool_input.
std::string s = line;
size_t pos = 0;
while (true) {
size_t k = s.find("\"text\"", pos);
if (k == std::string::npos) break;
size_t i = k + 6;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
if (i >= s.size() || s[i] != ':') { pos = k + 1; continue; }
++i;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
if (i >= s.size() || s[i] != '"') { pos = k + 1; continue; }
++i;
size_t start = i;
while (i < s.size()) {
if (s[i] == '\\' && i + 1 < s.size()) { i += 2; continue; }
if (s[i] == '"') break;
++i;
}
std::string text = json_unescape(s.data() + start, i - start);
if (!text.empty()) {
std::lock_guard<std::mutex> lk(g_st->mu);
ChatMessage m;
m.kind = ChatMessage::ASSISTANT;
m.text = text;
g_st->history.push_back(std::move(m));
g_st->scroll_to_bottom = true;
}
pos = i + 1;
}
// Detectar tool_use
if (line.find("\"type\":\"tool_use\"") != std::string::npos) {
std::string tname;
json_find_string(line, "name", &tname);
// Resumen del input — toma el primer campo string
std::string summary;
std::string command;
json_find_string(line, "command", &command);
// claude usa input:{...}; sacar la parte legible
size_t ip = line.find("\"input\":");
if (ip != std::string::npos) {
size_t end = line.find("}", ip);
if (end != std::string::npos) {
summary = line.substr(ip, end - ip + 1);
if (summary.size() > 240) summary = summary.substr(0, 240) + "...";
}
}
chat_log("tools", "tool_use name=%s cmd=%s",
tname.c_str(), command.c_str());
std::lock_guard<std::mutex> lk(g_st->mu);
ChatMessage m;
m.kind = ChatMessage::TOOL_USE;
m.tool_name = tname;
m.tool_input = summary;
g_st->history.push_back(std::move(m));
g_st->scroll_to_bottom = true;
}
return;
}
if (type == "user") {
// Mensajes user wrappean tool_result. Si contiene "tool_use_id" y
// "content", lo mostramos como TOOL_RESULT.
if (line.find("\"tool_use_id\"") != std::string::npos) {
std::string content;
// El "content" puede ser string o array — intento string primero
if (!json_find_string(line, "content", &content)) {
// saca lo que haya entre el primer "text":" y "
size_t k = line.find("\"text\":");
if (k != std::string::npos) {
size_t s = line.find('"', k + 7);
size_t e = (s == std::string::npos) ? std::string::npos
: line.find('"', s + 1);
if (e != std::string::npos)
content = json_unescape(line.data() + s + 1, e - s - 1);
}
}
if (content.size() > 1500) content = content.substr(0, 1500) + "\n... (truncado)";
std::lock_guard<std::mutex> lk(g_st->mu);
ChatMessage m;
m.kind = ChatMessage::TOOL_RESULT;
m.text = content;
g_st->history.push_back(std::move(m));
g_st->scroll_to_bottom = true;
}
return;
}
if (type == "result") {
std::string sub;
json_find_string(line, "subtype", &sub);
chat_log("parse", "result subtype=%s", sub.c_str());
if (sub == "error_max_turns" || sub == "error_during_execution") {
std::string err = "(claude error: " + sub + ")";
std::lock_guard<std::mutex> lk(g_st->mu);
ChatMessage m;
m.kind = ChatMessage::ERROR_MSG;
m.text = err;
g_st->history.push_back(std::move(m));
g_st->scroll_to_bottom = true;
}
return;
}
}
// ----------------------------------------------------------------------------
// System prompt para el agente
// ----------------------------------------------------------------------------
std::string build_system_prompt() {
return
"Eres el Agente — copiloto del usuario sobre navegator_dashboard. El\n"
"usuario controla N instancias de Chrome con --remote-debugging-port\n"
"lanzadas por el dashboard. Tu trabajo: ayudar a manejar esos\n"
"navegadores (lanzar, navegar, extraer, automatizar).\n\n"
"PROHIBIDO ABSOLUTO:\n"
"- NO ejecutar `navegator_dashboard.exe`, `$GX_APP_DIR/*.exe`, ni\n"
" ningun otro .exe del directorio del dashboard. Ese ejecutable ES\n"
" el dashboard que tu controlas — relanzarlo abre otra instancia\n"
" GUI inutil. SIEMPRE actua via la API HTTP o CDP HTTP de Chrome.\n"
"- NO usar `which gx-cli` ni `gx-cli` — son del otro proyecto\n"
" (graph_explorer), no estan en este equipo.\n"
"- NO inventar endpoints. Solo existen los 4 listados abajo.\n\n"
"ENTORNO:\n"
"- API HTTP local del dashboard (los UNICOS endpoints disponibles):\n"
" GET http://127.0.0.1:19333/health\n"
" GET http://127.0.0.1:19333/browsers lista chromes\n"
" POST http://127.0.0.1:19333/spawn?profile=X&port=N&headless=0|1\n"
" POST http://127.0.0.1:19333/kill?profile=X (o user_data_dir=...)\n"
" No hay /navigate ni /tabs ni /eval — esos van directo a CDP HTTP.\n"
"- CDP HTTP (cada Chrome instancia expone esto en su --remote-debugging-port):\n"
" GET http://127.0.0.1:<PORT>/json/version info navegador\n"
" GET http://127.0.0.1:<PORT>/json lista de tabs\n"
" PUT http://127.0.0.1:<PORT>/json/new?<URL> nueva tab abriendo URL\n"
" GET http://127.0.0.1:<PORT>/json/close/<id> cierra tab\n"
" GET http://127.0.0.1:<PORT>/json/activate/<id> foco tab\n"
" Para acciones complejas (eval JS, screenshot, click) sobre una\n"
" tab necesitas WebSocket CDP — eso queda fuera de tu alcance hoy\n"
" (no tienes cdp-cli desplegado). Limitate a HTTP.\n\n"
"WORKFLOW:\n"
"1. Listar Chromes: `curl -sf http://127.0.0.1:19333/browsers`.\n"
"2. Lanzar Chrome nuevo: `curl -sf -X POST 'http://127.0.0.1:19333/spawn?profile=NOMBRE&port=19299'`.\n"
"3. Para abrir URL en una instancia ya viva, usa CDP HTTP /json/new:\n"
" `curl -sf -X PUT 'http://127.0.0.1:<PORT>/json/new?<URL_ENCODED>'`.\n"
"4. Para listar tabs: `curl -sf http://127.0.0.1:<PORT>/json`.\n"
"5. Para cerrar tab: `curl -sf 'http://127.0.0.1:<PORT>/json/close/<TAB_ID>'`.\n"
"6. Cleanup: `curl -sf -X POST 'http://127.0.0.1:19333/kill?profile=NOMBRE'`.\n\n"
"REGLAS:\n"
"- Antes de spawn, comprueba /browsers — si el profile existe, reusa\n"
" su puerto (no lances duplicado).\n"
"- Puertos: usa siempre >= 19222 (el 9222 suele estar reservado en Windows).\n"
"- No mates Chromes que no hayas lanzado salvo orden explicita.\n"
"- Scraping → headless=1 (no roba foco).\n"
"- Ambiguedad → ejecuta con perfil 'tmp' y describe.\n"
"- Resume conciso. El usuario VE el dashboard. No narres lo obvio.\n"
" Importante: errores, hallazgos, sugerencias de proximo paso.\n\n"
"Firmas como Agente cuando tenga sentido marcar identidad.";
}
// Convierte un path Windows (UNC \\wsl.localhost\<distro>\... o C:\...) al
// equivalente WSL (/home/... o /mnt/c/...). En POSIX es no-op. Necesario
// para escribir el mcp.json: claude corre en WSL y lee paths Linux.
std::string to_linux_path(const std::string& p) {
#ifndef _WIN32
return p;
#else
if (p.empty()) return p;
if (p[0] == '/') return p;
auto is_sep = [](char c) { return c == '\\' || c == '/'; };
// UNC: \\<server>\<share>\rest
if (p.size() >= 2 && is_sep(p[0]) && is_sep(p[1])) {
size_t i = 2;
while (i < p.size() && !is_sep(p[i])) ++i;
std::string server = p.substr(2, i - 2);
for (auto& c : server) c = (char)std::tolower((unsigned char)c);
if (server == "wsl.localhost" || server == "wsl$") {
if (i < p.size()) ++i;
while (i < p.size() && !is_sep(p[i])) ++i;
std::string rest = p.substr(i);
for (char& c : rest) if (c == '\\') c = '/';
return rest.empty() ? std::string("/") : rest;
}
std::string out = p;
for (char& c : out) if (c == '\\') c = '/';
return out;
}
// Drive letter: X:\... -> /mnt/x/...
if (p.size() >= 3 && std::isalpha((unsigned char)p[0]) && p[1] == ':' &&
is_sep(p[2])) {
std::string out = "/mnt/";
out.push_back((char)std::tolower((unsigned char)p[0]));
for (size_t i = 2; i < p.size(); ++i) {
out.push_back(p[i] == '\\' ? '/' : p[i]);
}
return out;
}
return p;
#endif
}
// Escribe el fichero mcp.json que claude leera al arrancar para spawnar
// el MCP server gx-cli. Devuelve el path para --mcp-config (Linux dentro
// de WSL en Windows; native en Linux). El contenido del fichero usa paths
// Linux porque claude corre dentro de WSL.
std::string write_mcp_config() {
if (!g_st) return "";
std::string config_path_native; // path para fopen
std::string config_path_for_claude; // path que se pasa a --mcp-config
// Ponemos mcp.json junto a app_db (mismo dir que chat.log).
{
std::string p = g_st->app_db;
size_t slash = p.find_last_of("/\\");
size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1;
config_path_native = p.substr(0, dir_end) + "mcp.json";
}
#ifdef _WIN32
// claude corre en WSL — necesita el path Linux del config file.
// Pero to_linux_path solo convierte paths ABSOLUTOS. Si app_db era
// relativo (caso normal cuando cwd = directorio del exe), config_path
// tambien lo es. Resolvemos primero a absoluto Windows.
{
char buf[MAX_PATH * 2];
DWORD n = GetFullPathNameA(config_path_native.c_str(),
(DWORD)sizeof(buf), buf, nullptr);
if (n > 0 && n < sizeof(buf)) {
config_path_native = buf; // ahora absoluto: C:\Users\...\mcp.json
}
}
config_path_for_claude = to_linux_path(config_path_native);
#else
config_path_for_claude = config_path_native;
#endif
// gx-cli vive en <exe_dir>/assets/ tras el deploy con la
// convencion assets/. Fallback al app_dir del repo en modo dev.
std::string app_dir_linux = to_linux_path(g_st->app_dir);
std::string gxcli_path;
{
std::string assets_gx = fn::asset_path(
#ifdef _WIN32
"gx-cli.exe"
#else
"gx-cli"
#endif
);
std::error_code _ec;
if (std::filesystem::exists(assets_gx, _ec)) {
gxcli_path = to_linux_path(assets_gx);
} else {
gxcli_path = app_dir_linux + "/gx-cli";
}
}
// No metemos env: en el config para que herede del proceso padre
// (claude, que ya tiene GX_OPS_DB/GX_APP_DB/GX_APP_DIR via WSLENV).
std::ostringstream js;
js << "{\n"
<< " \"mcpServers\": {\n"
<< " \"graph_explorer\": {\n"
<< " \"command\": \"" << gxcli_path << "\",\n"
<< " \"args\": [\"mcp-server\"]\n"
<< " }\n"
<< " }\n"
<< "}\n";
FILE* f = std::fopen(config_path_native.c_str(), "wb");
if (!f) {
chat_log("error", "no pude escribir mcp.json en %s",
config_path_native.c_str());
return "";
}
std::string content = js.str();
std::fwrite(content.data(), 1, content.size(), f);
std::fclose(f);
chat_log("init", "mcp.json escrito en %s (claude lo lee como %s)",
config_path_native.c_str(), config_path_for_claude.c_str());
chat_log("init", "MCP server: %s mcp-server", gxcli_path.c_str());
return config_path_for_claude;
}
// ----------------------------------------------------------------------------
// Worker: ejecuta una vuelta completa y termina
// ----------------------------------------------------------------------------
void worker_send(std::string user_text) {
if (!g_st) return;
if (g_st->session_id.empty()) g_st->session_id = make_uuid_v4();
chat_log("spawn", "turno %d, session_id=%s, prompt_len=%zu bytes",
g_st->first_turn_done ? 2 : 1,
g_st->session_id.c_str(), user_text.size());
std::vector<std::string> argv;
argv.push_back(g_st->claude_cmd);
for (auto& a : g_st->claude_pre_args) argv.push_back(a);
argv.push_back("-p");
argv.push_back("--input-format"); argv.push_back("text");
argv.push_back("--output-format"); argv.push_back("stream-json");
argv.push_back("--verbose");
argv.push_back("--allowed-tools");
// mcp__graph_explorer__* expone las 17 tools tipadas. Bash queda como
// fallback para casos raros; el system prompt empuja a usar las MCP.
argv.push_back("Bash Read Glob Grep WebFetch");
argv.push_back("--permission-mode");
argv.push_back("bypassPermissions");
if (!g_st->mcp_config_path.empty()) {
argv.push_back("--mcp-config");
argv.push_back(g_st->mcp_config_path);
}
if (g_st->first_turn_done) {
argv.push_back("--resume"); argv.push_back(g_st->session_id);
} else {
argv.push_back("--session-id"); argv.push_back(g_st->session_id);
argv.push_back("--append-system-prompt");
argv.push_back(build_system_prompt());
// En la primera vuelta inyectamos las env-vars como contexto explicito
// porque no podemos confiar en que claude las herede en todos los
// entornos (Windows wsl.exe las propaga, Linux nativo tambien, pero
// documentarlo en el mensaje hace al agente independiente).
}
// Setenv para el subprocess (claude hereda env, lo pasa a Bash tool calls).
#ifndef _WIN32
setenv("NAVD_API_URL", "http://127.0.0.1:19333", 1);
setenv("NAVD_APP_DIR", g_st->app_dir.c_str(), 1);
// GX_* mantenidos por compat con codigo upstream del modulo (no aplican
// pero no estorban — counter de mutaciones queda en 0 de todas formas).
setenv("GX_OPS_DB", g_st->ops_db.c_str(), 1);
setenv("GX_APP_DB", g_st->app_db.c_str(), 1);
setenv("GX_APP_DIR", g_st->app_dir.c_str(), 1);
// Asegura que cdp-cli esta en PATH anteponiendo app_dir.
std::string path = g_st->app_dir;
if (const char* p = std::getenv("PATH")) {
path += ":";
path += p;
}
setenv("PATH", path.c_str(), 1);
chat_log("env", "NAVD_API_URL=http://127.0.0.1:19333");
chat_log("env", "NAVD_APP_DIR=%s", g_st->app_dir.c_str());
#else
// WSLENV /p traduce paths Windows -> WSL automaticamente, pero SOLO si
// el path es absoluto. Los relativos pasan literales (con backslash) y
// dentro de WSL no resuelven a nada — por eso el agente abria la BD
// equivocada. Resolvemos con GetFullPathNameA antes de setear.
auto abs_win = [](const std::string& p) -> std::string {
if (p.empty()) return p;
char buf[MAX_PATH * 2];
DWORD n = GetFullPathNameA(p.c_str(),
(DWORD)sizeof(buf), buf, nullptr);
if (n == 0 || n >= sizeof(buf)) return p;
return std::string(buf);
};
std::string ops_abs = abs_win(g_st->ops_db);
std::string appdb_abs = abs_win(g_st->app_db);
std::string appdir_abs;
// app_dir suele ser ya UNC \\wsl.localhost\... o ruta WSL — no queremos
// que GetFullPathNameA lo "windowsifique". Si empieza por \\ lo dejamos.
if (g_st->app_dir.size() >= 2 &&
g_st->app_dir[0] == '\\' && g_st->app_dir[1] == '\\') {
appdir_abs = g_st->app_dir;
} else {
appdir_abs = abs_win(g_st->app_dir);
}
SetEnvironmentVariableA("NAVD_API_URL", "http://127.0.0.1:19333");
SetEnvironmentVariableA("NAVD_APP_DIR", appdir_abs.c_str());
SetEnvironmentVariableA("GX_OPS_DB", ops_abs.c_str());
SetEnvironmentVariableA("GX_APP_DB", appdb_abs.c_str());
SetEnvironmentVariableA("GX_APP_DIR", appdir_abs.c_str());
SetEnvironmentVariableA(
"WSLENV", "NAVD_API_URL:NAVD_APP_DIR/p:GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p");
chat_log("env", "GX_OPS_DB=%s", ops_abs.c_str());
chat_log("env", "GX_APP_DB=%s", appdb_abs.c_str());
chat_log("env", "GX_APP_DIR=%s", appdir_abs.c_str());
chat_log("env", "WSLENV=GX_OPS_DB/p:GX_APP_DB/p:GX_APP_DIR/p");
#endif
// Log del argv completo
{
std::string joined;
for (size_t i = 0; i < argv.size(); ++i) {
if (i) joined += ' ';
joined += argv[i];
}
chat_log("spawn", "argv: %s", joined.c_str());
}
// En la primera vuelta inyectamos el contexto del entorno como prefijo
// del primer mensaje. NO imprimimos los paths Windows tal cual — WSLENV
// los traducira a /mnt/c/... dentro de WSL antes de que el agente vea
// las env vars. Lo importante es decirle al agente: usa las env vars
// GX_OPS_DB / GX_APP_DB / GX_APP_DIR que ya estan seteadas, NO inventes
// paths.
std::string prompt = user_text;
if (!g_st->first_turn_done) {
std::ostringstream ctx;
ctx << "[contexto del entorno — no respondas a esto, solo recuerdalo]\n"
<< "Las variables de entorno GX_OPS_DB, GX_APP_DB y GX_APP_DIR\n"
<< "estan seteadas en TU shell (bash). gx-cli las lee\n"
<< "automaticamente, asi que NUNCA pases --db ni configures paths\n"
<< "manualmente. Verifica con: `env | grep GX_`. La BD apuntada\n"
<< "por GX_OPS_DB es la que el usuario VE en el viewport.\n"
<< "Comando estandar: $GX_APP_DIR/gx-cli <subcomando> ...\n\n"
<< "[mensaje del usuario]\n"
<< user_text;
prompt = ctx.str();
}
auto t0 = std::chrono::steady_clock::now();
int rc = run_claude_streaming(argv, prompt, on_stream_line);
auto dt_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
chat_log("spawn", "subprocess exit rc=%d, duration=%lldms", rc, (long long)dt_ms);
if (rc != 0) {
std::lock_guard<std::mutex> lk(g_st->mu);
ChatMessage m;
m.kind = ChatMessage::ERROR_MSG;
m.text = "claude -p exit code " + std::to_string(rc) +
" — revisa " + g_log_path;
g_st->history.push_back(std::move(m));
g_st->scroll_to_bottom = true;
}
g_st->first_turn_done = true;
g_st->busy.store(false);
}
} // namespace
// ----------------------------------------------------------------------------
// API publica
// ----------------------------------------------------------------------------
bool chat_init(const char* ops_db_path, const char* app_db_path,
const char* app_dir)
{
if (!g_st) g_st = new State();
g_st->ops_db = ops_db_path ? ops_db_path : "";
g_st->app_db = app_db_path ? app_db_path : "";
g_st->app_dir = app_dir ? app_dir : "";
// Path del log: junto a app_db (= graph_explorer.db, sibling del exe en
// Windows). Si app_db esta vacio, fallback a stderr-only.
if (!g_st->app_db.empty()) {
std::string p = g_st->app_db;
// strip ".db" / sustituir extension
size_t slash = p.find_last_of("/\\");
size_t dir_end = (slash == std::string::npos) ? 0 : slash + 1;
g_log_path = p.substr(0, dir_end) + "agent.log";
// arranca con un separador para distinguir sesiones
FILE* f = std::fopen(g_log_path.c_str(), "ab");
if (f) {
std::fputs("\n========================================\n", f);
std::fclose(f);
}
}
chat_log("init", "ops_db=%s", g_st->ops_db.c_str());
chat_log("init", "app_db=%s", g_st->app_db.c_str());
chat_log("init", "app_dir=%s", g_st->app_dir.c_str());
chat_log("init", "log_path=%s", g_log_path.c_str());
detect_claude();
// navegator: NO escribimos mcp.json. cdp-cli aun no tiene mcp-server
// subcomando. claude usa Bash para invocar curl + cdp-cli directos —
// suficiente y simple. Si en v2 cdp-cli expone mcp-server, conectar aqui.
g_st->mcp_config_path = "";
return g_st->ready;
}
void chat_set_ops_db(const char* ops_db_path) {
if (!g_st) return;
if (!ops_db_path) return;
if (g_st->ops_db == ops_db_path) return;
g_st->ops_db = ops_db_path;
// resetear sesion para que el nuevo grafo se contextualice en turno 1
{
std::lock_guard<std::mutex> lk(g_st->mu);
g_st->session_id.clear();
g_st->first_turn_done = false;
g_st->history.push_back({ChatMessage::SYSTEM,
"[ops_db cambiada → nueva sesion]", "", ""});
}
// Regenerar mcp.json — el server hereda env del proceso claude que se
// arranca por turno, asi que con env-vars actualizadas en worker_send
// el MCP server vera los nuevos paths. El fichero mcp.json en si no
// contiene los paths, asi que tecnicamente no hace falta reescribir;
// lo regeneramos por si el path del propio mcp.json cambio.
if (g_st->ready) {
g_st->mcp_config_path = write_mcp_config();
}
}
void chat_send(const char* user_text) {
if (!g_st || !user_text || !*user_text) return;
chat_log("send", "user msg, %zu bytes, ready=%d busy=%d",
std::strlen(user_text), (int)g_st->ready,
(int)g_st->busy.load());
if (!g_st->ready) {
std::lock_guard<std::mutex> lk(g_st->mu);
g_st->history.push_back({ChatMessage::ERROR_MSG,
std::string("claude no detectado — revisa ") + g_log_path +
". En Windows necesitas Claude Code instalado en WSL.",
"", ""});
return;
}
if (g_st->busy.exchange(true)) {
chat_log("send", "ignorado: ya hay una vuelta en curso");
return;
}
{
std::lock_guard<std::mutex> lk(g_st->mu);
g_st->history.push_back({ChatMessage::USER, user_text, "", ""});
g_st->scroll_to_bottom = true;
}
if (g_st->worker.joinable()) g_st->worker.join();
g_st->worker = std::thread(worker_send, std::string(user_text));
}
int chat_mutations_counter() {
// Lee el mtime del marker file `.mutations.marker` junto a graph_explorer.db.
// Devuelve mtime en segundos como int — si cambia entre polls, el caller
// sabe que hay nueva mutacion. Si el fichero no existe, devuelve 0.
//
// Ventaja sobre tabla SQLite: stat() funciona cross-filesystem-boundary
// (NTFS <-> 9p) sin contencion. SQLite WAL no.
if (!g_st || g_st->app_db.empty()) return 0;
std::string marker = g_st->app_db;
size_t slash = marker.find_last_of("/\\");
if (slash != std::string::npos) {
marker = marker.substr(0, slash + 1) + ".mutations.marker";
} else {
marker = ".mutations.marker";
}
#ifdef _WIN32
WIN32_FILE_ATTRIBUTE_DATA fad;
if (!GetFileAttributesExA(marker.c_str(), GetFileExInfoStandard, &fad)) {
return 0;
}
// FILETIME es 100-nanoseg desde 1601 — suficiente como int monotono.
return (int)((((uint64_t)fad.ftLastWriteTime.dwHighDateTime << 32)
| fad.ftLastWriteTime.dwLowDateTime) / 10000000ULL);
#else
struct stat st;
if (::stat(marker.c_str(), &st) != 0) return 0;
return (int)st.st_mtime;
#endif
}
void chat_render(bool* panel_open) {
if (!g_st) return;
if (panel_open && !*panel_open) return;
if (!ImGui::Begin(TI_MESSAGE_CIRCLE " Agent", panel_open)) {
ImGui::End();
return;
}
// Status line + toolbar (Copy all, Clear-via-system).
auto copy_all_to_clipboard = []() {
std::lock_guard<std::mutex> lk(g_st->mu);
std::string buf;
buf.reserve(8192);
for (const auto& m : g_st->history) {
const char* role = "?";
switch (m.kind) {
case ChatMessage::USER: role = "You"; break;
case ChatMessage::ASSISTANT: role = "Agent"; break;
case ChatMessage::TOOL_USE: role = m.tool_name.empty() ? "tool" : m.tool_name.c_str(); break;
case ChatMessage::TOOL_RESULT: role = "result"; break;
case ChatMessage::SYSTEM: role = "system"; break;
case ChatMessage::ERROR_MSG: role = "error"; break;
}
buf += "## ";
buf += role;
buf += "\n";
if (m.kind == ChatMessage::TOOL_USE && !m.tool_input.empty()) {
buf += m.tool_input;
} else {
buf += m.text;
}
buf += "\n\n";
}
ImGui::SetClipboardText(buf.c_str());
};
if (!g_st->ready) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.3f, 1.0f),
TI_ALERT_TRIANGLE " claude no detectado");
ImGui::SameLine();
if (ImGui::SmallButton(TI_COPY " Copy all")) copy_all_to_clipboard();
ImGui::Separator();
} else {
ImGui::TextDisabled("ops_db: %s",
g_st->ops_db.empty() ? "(none)" : g_st->ops_db.c_str());
ImGui::SameLine();
ImGui::Checkbox("raw", &g_st->show_raw);
ImGui::SameLine();
if (ImGui::SmallButton(TI_COPY " Copy all")) copy_all_to_clipboard();
ImGui::Separator();
}
// History
float input_h = 80.0f;
ImGui::BeginChild("##history", ImVec2(0, -input_h - 4.0f), true);
{
std::lock_guard<std::mutex> lk(g_st->mu);
for (const auto& m : g_st->history) {
switch (m.kind) {
case ChatMessage::USER:
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f),
TI_USER " You");
fn_ui::selectable_text_wrapped_force(m.text.c_str());
break;
case ChatMessage::ASSISTANT:
ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.7f, 1.0f),
TI_ROBOT " Agent");
fn_ui::selectable_text_wrapped_force(m.text.c_str());
break;
case ChatMessage::TOOL_USE:
ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f),
TI_TOOL " %s", m.tool_name.empty()
? "(tool)" : m.tool_name.c_str());
if (!m.tool_input.empty()) {
fn_ui::selectable_text_wrapped_force(m.tool_input.c_str());
}
break;
case ChatMessage::TOOL_RESULT:
ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f),
TI_CORNER_DOWN_LEFT " result");
fn_ui::selectable_text_wrapped_force(m.text.c_str());
break;
case ChatMessage::SYSTEM:
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
ImGui::TextWrapped("%s", m.text.c_str());
ImGui::PopStyleColor();
break;
case ChatMessage::ERROR_MSG:
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(1.0f, 0.4f, 0.3f, 1.0f));
fn_ui::selectable_text_wrapped_force(m.text.c_str());
ImGui::PopStyleColor();
break;
}
ImGui::Spacing();
}
if (g_st->show_raw) {
ImGui::Separator();
ImGui::TextDisabled("== raw stream-json ==");
for (const auto& l : g_st->raw_lines) {
ImGui::TextWrapped("%s", l.c_str());
}
}
}
if (g_st->scroll_to_bottom) {
ImGui::SetScrollHereY(1.0f);
g_st->scroll_to_bottom = false;
}
ImGui::EndChild();
// Spinner
if (g_st->busy.load()) {
ImGui::Text(TI_LOADER " thinking...");
} else {
ImGui::Dummy(ImVec2(0, ImGui::GetTextLineHeight()));
}
// Input
ImGui::PushItemWidth(-80.0f);
bool send = ImGui::InputTextMultiline(
"##chat_input", g_st->input_buf, sizeof(g_st->input_buf),
ImVec2(0, input_h - 4.0f),
ImGuiInputTextFlags_EnterReturnsTrue
| ImGuiInputTextFlags_CtrlEnterForNewLine);
ImGui::PopItemWidth();
ImGui::SameLine();
if (ImGui::Button("Send", ImVec2(72.0f, input_h - 4.0f))) send = true;
if (send && !g_st->busy.load() && g_st->input_buf[0]) {
chat_send(g_st->input_buf);
g_st->input_buf[0] = 0;
}
ImGui::End();
}
void chat_shutdown() {
if (!g_st) return;
if (g_st->worker.joinable()) {
// No bloqueamos forzosamente — claude -p termina solo al cerrar stdin
g_st->worker.join();
}
delete g_st;
g_st = nullptr;
}
} // namespace app_agent