feat(chat): panel Echo + gx-cli MCP server con tools tipadas

Anade panel "Echo" — copiloto OSINT que invoca claude -p con un MCP
server propio (gx-cli) exponiendo el grafo como tools tipadas:
info, node_*, rel_*, table_*, enricher_*, query.

Cambios:
- chat.cpp/h: panel UI dockeable con history, raw stream-json toggle,
  spawn de claude -p con system prompt OSINT, ChatMessage con USER/
  ASSISTANT/TOOL_USE/TOOL_RESULT/SYSTEM/ERROR_MSG, escritura de
  mcp.json con paths Linux para WSL en Windows.
- gx-cli: binario MCP standalone que valida cada tool, abre
  operations.db en RW, escribe agent_mutations counter para que el
  viewport detecte cambios en vivo.
- CMakeLists.txt: anade chat.cpp al target.
- views.h: panel_chat boolean en AppState.
- main.cpp: integracion del panel Chat (rename a Echo en menubar +
  init), refresh de contexto al cambiar operations.db, drain de cola
  agent_jobs tras enricher_run.

Mensajes del panel renderizan con fn_ui::selectable_text_wrapped_force
(wrap forzado + seleccion) para que URLs/JSON largos no se clippeen
y permitan copy/paste.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:10:01 +02:00
parent 4281f3ccb2
commit 0d2450bac5
6 changed files with 2731 additions and 100 deletions
+405 -100
View File
@@ -8,6 +8,8 @@
#include "core/button.h"
#include "core/tokens.h"
#include "core/icons_tabler.h"
#include "core/layout_storage.h"
#include "core/layouts_menu.h"
#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
@@ -27,6 +29,7 @@
#include "project_manager.h"
#include "jobs.h"
#include "enrichers.h"
#include "chat.h"
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
@@ -73,6 +76,98 @@ static std::string g_layout_db_path; // ruta de graph_explorer.db
static ForceLayoutGPU* g_gpu_ctx = nullptr;
static bool g_gpu_dirty = true;
// Layout storage (menu Layouts) — guardado/cargado de layouts ImGui en
// graph_explorer.db tabla imgui_layouts.
static fn_ui::LayoutStorage* g_layout_storage = nullptr;
static fn_ui::LayoutCallbacks g_layout_cb{};
// ----------------------------------------------------------------------------
// Persistencia de paneles abiertos/cerrados
//
// Los toggles `panel_chat`, `panel_jobs`, etc. viven en AppState (RAM). Sin
// persistencia, al reabrir la app vuelven a sus defaults — el usuario tiene
// que reabrir manualmente cada panel cada vez.
//
// Tabla `panel_state(name TEXT PK, open INT, updated_at INT)` en la misma
// graph_explorer.db. load al arrancar, save al cerrar.
// ----------------------------------------------------------------------------
static void panel_state_ensure_table(sqlite3* db) {
sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS panel_state ("
" name TEXT PRIMARY KEY,"
" open INTEGER NOT NULL,"
" updated_at INTEGER NOT NULL)",
nullptr, nullptr, nullptr);
}
static void panel_state_load_db(const std::string& db_path,
fn_ui::PanelToggle* panels, size_t n) {
if (db_path.empty()) return;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
panel_state_ensure_table(db);
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT open FROM panel_state WHERE name = ?",
-1, &st, nullptr) == SQLITE_OK) {
int restored = 0;
for (size_t i = 0; i < n; ++i) {
if (!panels[i].open || !panels[i].label) continue;
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
*panels[i].open = (sqlite3_column_int(st, 0) != 0);
++restored;
}
sqlite3_reset(st);
}
sqlite3_finalize(st);
std::fprintf(stdout,
"[graph_explorer] panel_state: restored %d/%zu panels\n",
restored, n);
}
sqlite3_close(db);
}
static void panel_state_save_db(const std::string& db_path,
const fn_ui::PanelToggle* panels, size_t n) {
if (db_path.empty()) return;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
panel_state_ensure_table(db);
sqlite3_stmt* st = nullptr;
const char* sql =
"INSERT INTO panel_state(name, open, updated_at) "
"VALUES (?, ?, strftime('%s','now')) "
"ON CONFLICT(name) DO UPDATE SET "
" open = excluded.open, "
" updated_at = excluded.updated_at";
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) {
int saved = 0;
for (size_t i = 0; i < n; ++i) {
if (!panels[i].open || !panels[i].label) continue;
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
sqlite3_bind_int (st, 2, *panels[i].open ? 1 : 0);
if (sqlite3_step(st) == SQLITE_DONE) ++saved;
sqlite3_reset(st);
}
sqlite3_finalize(st);
std::fprintf(stdout,
"[graph_explorer] panel_state: saved %d/%zu panels\n", saved, n);
}
sqlite3_close(db);
}
// Icon atlas (de types.yaml)
static IconAtlas* g_atlas = nullptr;
static bool g_atlas_bound = false;
@@ -142,86 +237,139 @@ static int layout_first_placed_neighbor(const GraphData& g, int node_idx) {
return -1;
}
// Coloca todos los nodos del grafo que esten en (0,0) cerca de su primer
// vecino con posicion conocida, eligiendo el primer slot angular libre
// dentro de un radio creciente. Sin vecinos colocados → aparca en columna
// a la derecha del bbox actual.
static void place_orphans_near_neighbors(GraphData& g, float min_dist) {
if (g.node_count == 0) return;
const float radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
const int n_radii = (int)(sizeof(radii) / sizeof(radii[0]));
// Encuentra una posicion sin colision para `self_idx` haciendo un barrido
// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve
// true y escribe (out_x, out_y); false si no hay hueco en los radios
// disponibles. `seed` se usa para jitter deterministico (ej: user_data).
static bool find_collision_free_slot(const GraphData& g, int self_idx,
float cx, float cy, float min_dist,
uint64_t seed,
const float* radii, int n_radii,
float* out_x, float* out_y)
{
const int slots = 12;
const float two_pi = 6.28318530718f;
const float slot_arc = two_pi / slots;
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * slot_arc;
// Recompute bbox solo de los nodos colocados (para park_in_column).
float bbox_max_x = 0.0f, bbox_min_y = 0.0f, bbox_max_y = 0.0f;
// Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto.
if (layout_no_collision(g, self_idx, cx, cy, min_dist)) {
*out_x = cx; *out_y = cy;
return true;
}
for (int ri = 0; ri < n_radii; ++ri) {
float r = radii[ri];
for (int s = 0; s < slots; ++s) {
float a = jitter + s * slot_arc;
float px = cx + r * std::cos(a);
float py = cy + r * std::sin(a);
if (layout_no_collision(g, self_idx, px, py, min_dist)) {
*out_x = px; *out_y = py;
return true;
}
}
}
return false;
}
// Coloca todos los nodos del grafo que esten en (0,0):
// 1. Si tiene un vecino con posicion → ring placement junto al vecino.
// 2. Sin vecino: si `use_camera` → ring placement alrededor de la camara
// (cam_cx, cam_cy) con un radio inicial proporcional al zoom — asi
// los nodos creados por el agente aparecen DENTRO de la vista actual,
// sin solapar con lo que ya hay en pantalla.
// 3. Sin vecino y sin camera → fallback legacy: columna a la derecha del
// bbox (usado en first-load donde el viewport todavia no se ha hecho
// fit).
static void place_orphans_near_neighbors(GraphData& g, float min_dist,
bool use_camera = false,
float cam_cx = 0.0f,
float cam_cy = 0.0f,
float cam_radius = 120.0f) {
if (g.node_count == 0) return;
const float neighbor_radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
const int n_neighbor_radii = (int)(sizeof(neighbor_radii) /
sizeof(neighbor_radii[0]));
// Anillos crecientes alrededor de la camara — empieza pequeno (cam_radius
// base ~viewport/zoom) para mantener los nuevos cerca del foco visual.
float cam_radii[6];
for (int i = 0; i < 6; ++i) cam_radii[i] = cam_radius * (1.0f + i * 0.6f);
// Bbox para fallback legacy (columna lateral) cuando use_camera=false.
float bbox_max_x = 0.0f, bbox_min_y = 0.0f;
bool bbox_init = false;
for (int i = 0; i < g.node_count; ++i) {
const GraphNode& n = g.nodes[i];
if (n.x == 0.0f && n.y == 0.0f) continue;
if (!bbox_init) {
bbox_max_x = n.x; bbox_min_y = n.y; bbox_max_y = n.y;
bbox_init = true;
} else {
if (n.x > bbox_max_x) bbox_max_x = n.x;
if (n.y < bbox_min_y) bbox_min_y = n.y;
if (n.y > bbox_max_y) bbox_max_y = n.y;
if (!use_camera) {
for (int i = 0; i < g.node_count; ++i) {
const GraphNode& n = g.nodes[i];
if (n.x == 0.0f && n.y == 0.0f) continue;
if (!bbox_init) {
bbox_max_x = n.x; bbox_min_y = n.y;
bbox_init = true;
} else {
if (n.x > bbox_max_x) bbox_max_x = n.x;
if (n.y < bbox_min_y) bbox_min_y = n.y;
}
}
}
float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f;
float park_y = bbox_init ? bbox_min_y : 0.0f;
int park_n = 0;
int placed = 0, parked = 0;
int placed_neighbor = 0, placed_camera = 0, parked = 0;
for (int i = 0; i < g.node_count; ++i) {
GraphNode& n = g.nodes[i];
if (n.x != 0.0f || n.y != 0.0f) continue;
int parent = layout_first_placed_neighbor(g, i);
if (parent < 0) {
// Sin vecino colocado: aparca en columna lateral, separados
// verticalmente por min_dist. Si tampoco hay bbox (grafo
// recien creado), columna en (0, 0) hacia abajo.
n.x = park_x;
n.y = park_y + park_n * min_dist;
n.vx = 0.0f; n.vy = 0.0f;
++park_n; ++parked;
if (parent >= 0) {
float ox, oy;
if (find_collision_free_slot(
g, i, g.nodes[parent].x, g.nodes[parent].y,
min_dist, n.user_data,
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
n.x = ox; n.y = oy;
} else {
// Acepta solape como ultimo recurso.
n.x = g.nodes[parent].x + neighbor_radii[n_neighbor_radii - 1];
n.y = g.nodes[parent].y;
}
n.vx = n.vy = 0.0f;
++placed_neighbor;
continue;
}
// Jitter deterministico por user_data → dos huerfanos del mismo
// padre eligen ciclos diferentes y no se solapan.
float jitter = ((float)((n.user_data >> 16) & 0xFF) / 255.0f) * slot_arc;
bool placed_ok = false;
for (int ri = 0; ri < n_radii && !placed_ok; ++ri) {
float r = radii[ri];
for (int s = 0; s < slots && !placed_ok; ++s) {
float a = jitter + s * slot_arc;
float cx = g.nodes[parent].x + r * std::cos(a);
float cy = g.nodes[parent].y + r * std::sin(a);
if (layout_no_collision(g, i, cx, cy, min_dist)) {
n.x = cx; n.y = cy;
n.vx = 0.0f; n.vy = 0.0f;
placed_ok = true;
}
if (use_camera) {
// Sin vecino → colocar dentro de la camara con ring placement.
float ox, oy;
if (find_collision_free_slot(
g, i, cam_cx, cam_cy, min_dist, n.user_data,
cam_radii, 6, &ox, &oy)) {
n.x = ox; n.y = oy;
} else {
// Anillo amplio aceptando solape.
float two_pi = 6.28318530718f;
float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi;
float r = cam_radii[5];
n.x = cam_cx + std::cos(a) * r;
n.y = cam_cy + std::sin(a) * r;
}
n.vx = n.vy = 0.0f;
++placed_camera;
continue;
}
if (!placed_ok) {
// Fallback: pone en el ultimo radio + slot 0 (acepta solape).
float r = radii[n_radii - 1];
n.x = g.nodes[parent].x + r * std::cos(jitter);
n.y = g.nodes[parent].y + r * std::sin(jitter);
n.vx = 0.0f; n.vy = 0.0f;
}
++placed;
// Legacy: columna lateral (fuera de cam — usado en first_load).
n.x = park_x;
n.y = park_y + park_n * min_dist;
n.vx = n.vy = 0.0f;
++park_n; ++parked;
}
if (placed > 0 || parked > 0) {
if (placed_neighbor || placed_camera || parked) {
std::fprintf(stdout,
"[graph_explorer] placed %d orphans near neighbors, %d parked in column\n",
placed, parked);
"[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n",
placed_neighbor, placed_camera, parked);
}
}
@@ -424,35 +572,37 @@ static bool load_input(bool first_load) {
g_viewport.layout_running = false;
g_viewport.layout_energy = 0.0f;
// Posicionar nodos en primera carga: si todos tienen (x,y)=0, aplicar
// layout circular como arranque. En reloads NO — los huerfanos los
// resuelve `place_orphans_near_neighbors` despues de layout_store_load
// (issue 0031).
if (first_load) {
int zero_pos = 0;
for (int i = 0; i < g_graph.node_count; ++i) {
if (g_graph.nodes[i].x == 0.0f && g_graph.nodes[i].y == 0.0f) ++zero_pos;
}
if (zero_pos == g_graph.node_count) {
graph::layout_circular(g_graph, 200.0f);
}
}
g_graph.update_bounds();
// Indice user_data -> sql id (para CRUD desde menu contextual).
ge::entity_index_build(g_input.uri, &g_idx);
g_app.input_db_path = g_input.uri ? g_input.uri : "";
// issue 0026 — apunta el JobRunner a la nueva operations.db.
if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
// Chat agent — refrescar contexto de la nueva operations.db.
if (g_input.uri) ge::chat_set_ops_db(g_input.uri);
// Cargar posiciones guardadas para este graph_hash
// Cargar posiciones guardadas para este graph_hash. Ahora ANTES del
// bootstrap circular: si tenemos posiciones guardadas las respetamos;
// solo aplicamos circular si NO hay nada guardado en primera carga.
g_graph_hash = ge::compute_graph_hash(g_input.uri);
int restored = ge::layout_store_load(g_graph_hash, g_graph);
if (restored > 0) {
std::fprintf(stdout, "[graph_explorer] restored %d node positions from layout store\n", restored);
std::fprintf(stdout,
"[graph_explorer] restored %d node positions from layout store\n",
restored);
}
// Bootstrap circular SOLO si no se restauro nada en primera carga (ej:
// primer arranque tras crear el proyecto, o tras `Reset layout`). Si
// restored>0 los nodos cargados ya tienen posicion; los nuevos sin
// posicion guardada los colocara place_orphans_near_neighbors.
if (first_load && restored == 0 && g_graph.node_count > 0) {
graph::layout_circular(g_graph, 200.0f);
std::fprintf(stdout,
"[graph_explorer] bootstrap layout_circular (no saved positions)\n");
}
g_graph.update_bounds();
// Huerfanos (nodos sin posicion guardada): halo placement junto a su
// primer vecino con coordenadas conocidas (issue 0031). En primera carga
// tambien aplica — si layout_circular ya los puso en circulo, no entran
@@ -754,11 +904,23 @@ static fn_ui::PanelToggle g_panels[] = {
{"Types", nullptr, &g_app.panel_type_editor},
{"Table", nullptr, &g_app.panel_table},
{"Jobs", nullptr, &g_app.panel_jobs},
{"Echo", nullptr, &g_app.panel_chat},
};
static void render() {
update_fps();
// Aplicar layout pendiente (si el usuario seleccionó uno del menu Layouts).
// Debe ir antes de crear ventanas — LoadIniSettingsFromMemory afecta a las
// posiciones que se calculan a continuación.
if (g_layout_storage) {
std::string applied = fn_ui::layout_storage_apply_pending(g_layout_storage);
if (!applied.empty()) {
std::fprintf(stdout, "[graph_explorer] layout aplicado: %s\n",
applied.c_str());
}
}
// No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].
if (!g_loaded) {
@@ -848,6 +1010,81 @@ static void render() {
}
}
// Chat agent — gx-cli toca .mutations.marker tras cada mutacion.
// Polleamos su mtime cada N frames; si cambia, recargamos el grafo.
// (Antes usaba un contador en agent_mutations.SQLite, pero WAL falla
// cross-NTFS<->9p cuando el .exe Windows tiene la BD abierta.)
{
static int s_last_mut = -1; // -1 = primera lectura no hecha
static int s_throttle = 0;
if (++s_throttle >= 8) {
s_throttle = 0;
int m = ge::chat_mutations_counter();
if (s_last_mut == -1) {
// Primera lectura: solo memorizar, sin disparar reload.
s_last_mut = m;
} else if (m != s_last_mut) {
ge::chat_log("mut",
"marker mtime cambio %d -> %d, disparando reload",
s_last_mut, m);
s_last_mut = m;
g_app.want_reload = true;
}
}
}
// Chat agent — drena cola agent_jobs (gx-cli enricher run) e invoca
// jobs_submit() para que el worker pool corriendo en C++ haga el trabajo.
if (!g_layout_db_path.empty()) {
sqlite3* adb = nullptr;
if (sqlite3_open_v2(g_layout_db_path.c_str(), &adb,
SQLITE_OPEN_READWRITE, nullptr) == SQLITE_OK) {
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(adb,
"SELECT id, enricher_id, node_id, node_name, params_json "
"FROM agent_jobs ORDER BY created_at LIMIT 8",
-1, &st, nullptr) == SQLITE_OK) {
std::vector<std::string> ids_to_drop;
while (sqlite3_step(st) == SQLITE_ROW) {
const char* req_id = (const char*)sqlite3_column_text(st, 0);
const char* enr_id = (const char*)sqlite3_column_text(st, 1);
const char* node = (const char*)sqlite3_column_text(st, 2);
const char* nname = (const char*)sqlite3_column_text(st, 3);
const char* params = (const char*)sqlite3_column_text(st, 4);
char job_id[64];
if (ge::jobs_submit(enr_id ? enr_id : "",
node ? node : "",
nname ? nname : "",
params ? params : "{}",
job_id, sizeof(job_id))) {
std::fprintf(stdout,
"[chat] queued enricher=%s node=%s as %s (req=%s)\n",
enr_id ? enr_id : "", node ? node : "", job_id,
req_id ? req_id : "");
if (req_id) ids_to_drop.push_back(req_id);
g_app.panel_jobs = true;
} else {
std::fprintf(stderr,
"[chat] jobs_submit failed (req=%s enricher=%s)\n",
req_id ? req_id : "", enr_id ? enr_id : "");
}
}
sqlite3_finalize(st);
for (auto& id : ids_to_drop) {
sqlite3_stmt* d = nullptr;
if (sqlite3_prepare_v2(adb,
"DELETE FROM agent_jobs WHERE id = ?",
-1, &d, nullptr) == SQLITE_OK) {
sqlite3_bind_text(d, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(d);
sqlite3_finalize(d);
}
}
}
sqlite3_close(adb);
}
}
// Triggers desde la toolbar
if (g_app.want_fit) {
graph_viewport_fit(g_graph, g_viewport);
@@ -867,20 +1104,37 @@ static void render() {
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
// Reaplica types.yaml + atlas. Sin esto, los tipos pierden
// color/shape/icon tras reload (todo nodo vuelve a circulo
// gris). Mismo flujo que reload_after_mutation.
if (!g_app.parsed_types.entities.empty() ||
!g_app.parsed_types.relations.empty()) {
std::vector<uint16_t> cps =
ge::apply_types_yaml(g_graph, g_app.parsed_types);
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(cps);
}
// Restaura posiciones guardadas para nodos preexistentes.
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
// (C) Halo placement: huerfanos creados por enrichers se
// colocan junto a su primer vecino con posicion conocida,
// evitando solapamiento con nodos existentes (issue 0031).
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
// Halo placement: junto a vecino con posicion conocida; si no
// tiene vecino (caso tipico cuando el agente crea un nodo
// aislado via MCP node_create), DENTRO de la camara visible
// con anti-colision. Convencion: world_pos == cam_pos cuando
// el nodo cae en el centro de la pantalla (graph_viewport.cpp
// L23: gx = (vx - center) / zoom + cam_x).
float cam_cx = g_viewport.cam_x;
float cam_cy = g_viewport.cam_y;
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
? g_viewport.zoom : 0.01f);
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
/*use_camera=*/true,
cam_cx, cam_cy, cam_r);
g_graph.update_bounds();
// (B) NO graph_viewport_fit en reloads: preserva camara del
// usuario (issue 0031).
// (E) Physics siempre pausadas tras reload (issue 0031).
// Physics pausadas tras reload (issue 0031).
g_viewport.layout_running = false;
// Refresca el indice user_data -> sql id (puede haber nuevos
@@ -1051,25 +1305,17 @@ static void render() {
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
// Centro del area visible en world coords (para que los nuevos nodos
// aparezcan donde el usuario esta mirando, no en el origen).
float cx = -g_viewport.cam_x;
float cy = -g_viewport.cam_y;
float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f);
// Reparte los nodos sin posicion en un anillo poisson alrededor del
// centro visible. Determinista por user_data para que el mismo nodo
// caiga siempre en el mismo sitio entre reloads.
for (int i = 0; i < g_graph.node_count; ++i) {
GraphNode& n = g_graph.nodes[i];
if (n.x != 0.0f || n.y != 0.0f) continue;
uint64_t h = n.user_data ? n.user_data : (uint64_t)i * 2654435761ull;
float a = (float)((h >> 0) & 0xFFFF) / 65535.0f * 6.2831853f;
float r = spread_r * (0.4f + (float)((h >> 16) & 0xFFFF) / 65535.0f * 0.6f);
n.x = cx + std::cos(a) * r;
n.y = cy + std::sin(a) * r;
n.vx = n.vy = 0.0f;
}
// Halo placement: prefiere vecino, fallback a la camara con anti-
// colision. Los nodos nuevos aparecen DENTRO de la camara y NO
// encima de otros — el usuario los ve sin pan/zoom.
// (cam_x, cam_y) es el world point en el centro de la pantalla.
float cam_cx = g_viewport.cam_x;
float cam_cy = g_viewport.cam_y;
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
? g_viewport.zoom : 0.01f);
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
/*use_camera=*/true,
cam_cx, cam_cy, cam_r);
g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
@@ -1480,6 +1726,12 @@ static void render() {
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
ge::views_jobs(g_app);
// Chat panel (claude -p) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.55f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
ge::chat_render(&g_app.panel_chat);
g_first_render = false;
}
@@ -1666,6 +1918,8 @@ int main(int argc, char** argv) {
if (!g_input_path.empty()) {
load_input();
}
panel_state_load_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
} else {
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
// crea default si no existe ninguno.
@@ -1695,6 +1949,8 @@ int main(int argc, char** argv) {
ge::layout_store_open(g_layout_db_path.c_str());
ge::project_settings_touch(target.c_str());
load_input();
panel_state_load_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
}
fn_ui::about_window_set_info(
@@ -1716,6 +1972,27 @@ int main(int argc, char** argv) {
const char* app_db = g_layout_db_path.empty()
? "graph_explorer.db" : g_layout_db_path.c_str();
// Layout storage — guardado/cargado de layouts ImGui en
// graph_explorer.db. El menu Layouts del menubar consume estos cb.
if (g_layout_db_path.empty()) {
std::fprintf(stderr,
"[graph_explorer] layout storage skipped (no db_path)\n");
} else {
g_layout_storage = fn_ui::layout_storage_open(
g_layout_db_path.c_str());
if (g_layout_storage) {
fn_ui::layout_storage_make_callbacks(
g_layout_storage, g_layout_cb);
std::fprintf(stdout,
"[graph_explorer] layout storage abierto en %s\n",
g_layout_db_path.c_str());
} else {
std::fprintf(stderr,
"[graph_explorer] layout_storage_open fallo: %s\n",
g_layout_db_path.c_str());
}
}
ge::enrichers_load(enrichers_dir.c_str());
if (!ge::jobs_init(app_db,
g_input.uri ? g_input.uri : "",
@@ -1730,6 +2007,16 @@ int main(int argc, char** argv) {
enrichers_dir.c_str(), registry_root.c_str(),
(int)ge::enrichers_all().size());
}
// Chat panel (claude -p) — el agente invoca gx-cli para mutar
// operations.db. agent_mutations counter en graph_explorer.db dispara
// reload del viewport en cada cambio.
if (!ge::chat_init(g_input.uri ? g_input.uri : "",
app_db, app_dir.c_str())) {
std::fprintf(stderr,
"[graph_explorer] chat_init: claude no detectado "
"(panel Chat deshabilitado)\n");
}
}
int rc = fn::run_app(
@@ -1739,11 +2026,29 @@ int main(int argc, char** argv) {
.viewports = true,
.panels = g_panels,
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
.layouts_cb = g_layout_storage ? &g_layout_cb : nullptr,
.init_gl_loader = true},
render);
// Auto-save de posiciones de nodos al salir — sin esto las posiciones se
// pierden si el usuario nunca presiona "Save layout" (issue 0031 + nudge).
if (g_loaded && g_graph_hash != 0) {
int n = ge::layout_store_save(g_graph_hash, g_graph);
std::fprintf(stdout,
"[graph_explorer] auto-saved %d node positions on exit\n", n);
}
// Auto-save de paneles abiertos/cerrados al salir.
panel_state_save_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
// Cleanup
ge::chat_shutdown();
ge::jobs_shutdown();
if (g_layout_storage) {
fn_ui::layout_storage_close(g_layout_storage);
g_layout_storage = nullptr;
}
if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
if (g_atlas) graph_icons_destroy(g_atlas);
graph_viewport_destroy(g_viewport);