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:
@@ -26,6 +26,7 @@ add_imgui_app(graph_explorer
|
|||||||
tableview.cpp
|
tableview.cpp
|
||||||
jobs.cpp
|
jobs.cpp
|
||||||
enrichers.cpp
|
enrichers.cpp
|
||||||
|
chat.cpp
|
||||||
# --- viz ---
|
# --- viz ---
|
||||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
||||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// Panel Chat — agente Claude (claude -p) con tool-use sobre operations.db
|
||||||
|
// via gx-cli. Subprocess persistente bidireccional (stdin/stdout JSON-lines).
|
||||||
|
// El usuario escribe, el hilo lector parsea stream-json y va emitiendo
|
||||||
|
// fragmentos al historial. gx-cli muta operations.db; el contador
|
||||||
|
// agent_mutations en graph_explorer.db dispara reload del viewport.
|
||||||
|
|
||||||
|
namespace ge {
|
||||||
|
|
||||||
|
// Inicia el subprocess claude -p (lazy: hasta el primer mensaje no se
|
||||||
|
// arranca). Setea env vars GX_OPS_DB / GX_APP_DB / GX_APP_DIR. Devuelve
|
||||||
|
// false si claude no esta disponible (o, en Windows, wsl no esta).
|
||||||
|
bool chat_init(const char* ops_db_path,
|
||||||
|
const char* app_db_path,
|
||||||
|
const char* app_dir);
|
||||||
|
|
||||||
|
// Si la ops_db cambia (proyecto switch), refresca env del subprocess
|
||||||
|
// matandolo y dejando que el siguiente send lo reabra.
|
||||||
|
void chat_set_ops_db(const char* ops_db_path);
|
||||||
|
|
||||||
|
// Envia un mensaje del usuario al agente. Si el subprocess no esta vivo,
|
||||||
|
// lo arranca primero. No bloquea — el resultado llega via chat_render
|
||||||
|
// al ir vaciando la cola del hilo lector.
|
||||||
|
void chat_send(const char* user_text);
|
||||||
|
|
||||||
|
// Renderiza el panel ImGui (titulo "Chat"). Drena cola de mensajes del
|
||||||
|
// hilo lector. `panel_open` es bound al close button.
|
||||||
|
void chat_render(bool* panel_open);
|
||||||
|
|
||||||
|
// Cierra el subprocess y libera recursos. Llamar en shutdown.
|
||||||
|
void chat_shutdown();
|
||||||
|
|
||||||
|
// Counter de mutaciones (lee tabla agent_mutations en app_db). Se llama
|
||||||
|
// desde main.cpp cada frame para detectar si gx-cli muto algo y disparar
|
||||||
|
// reload del grafo. Devuelve 0 si la tabla no existe todavia.
|
||||||
|
int chat_mutations_counter();
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Logging con tags
|
||||||
|
//
|
||||||
|
// Todas las trazas del subsistema chat van a `<app_dir>/chat.log` ademas de
|
||||||
|
// stderr. Cada linea tiene formato:
|
||||||
|
//
|
||||||
|
// 2026-05-01T18:35:50.853Z [chat:detect] mensaje
|
||||||
|
//
|
||||||
|
// Tags usados (grep amigable):
|
||||||
|
// detect deteccion de claude/wsl al arrancar
|
||||||
|
// env env vars seteadas para el subprocess
|
||||||
|
// spawn argv completo + cwd al lanzar el subprocess
|
||||||
|
// io operaciones sobre los pipes (lectura/escritura/EOF)
|
||||||
|
// parse eventos JSON parseados desde stream-json
|
||||||
|
// tools tool_use detectados, comandos Bash invocados
|
||||||
|
// mut cambios detectados via agent_mutations.counter
|
||||||
|
// error fallos y exit codes
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
void chat_log(const char* tag, const char* fmt, ...);
|
||||||
|
|
||||||
|
// Devuelve el path absoluto del fichero de log (vacio si no inicializado).
|
||||||
|
const char* chat_log_path();
|
||||||
|
|
||||||
|
} // namespace ge
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
#include "core/button.h"
|
#include "core/button.h"
|
||||||
#include "core/tokens.h"
|
#include "core/tokens.h"
|
||||||
#include "core/icons_tabler.h"
|
#include "core/icons_tabler.h"
|
||||||
|
#include "core/layout_storage.h"
|
||||||
|
#include "core/layouts_menu.h"
|
||||||
|
|
||||||
#include "viz/graph_types.h"
|
#include "viz/graph_types.h"
|
||||||
#include "viz/graph_viewport.h"
|
#include "viz/graph_viewport.h"
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
#include "project_manager.h"
|
#include "project_manager.h"
|
||||||
#include "jobs.h"
|
#include "jobs.h"
|
||||||
#include "enrichers.h"
|
#include "enrichers.h"
|
||||||
|
#include "chat.h"
|
||||||
|
|
||||||
#include "../../../../cpp/vendor/sqlite3/sqlite3.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 ForceLayoutGPU* g_gpu_ctx = nullptr;
|
||||||
static bool g_gpu_dirty = true;
|
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)
|
// Icon atlas (de types.yaml)
|
||||||
static IconAtlas* g_atlas = nullptr;
|
static IconAtlas* g_atlas = nullptr;
|
||||||
static bool g_atlas_bound = false;
|
static bool g_atlas_bound = false;
|
||||||
@@ -142,86 +237,139 @@ static int layout_first_placed_neighbor(const GraphData& g, int node_idx) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coloca todos los nodos del grafo que esten en (0,0) cerca de su primer
|
// Encuentra una posicion sin colision para `self_idx` haciendo un barrido
|
||||||
// vecino con posicion conocida, eligiendo el primer slot angular libre
|
// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve
|
||||||
// dentro de un radio creciente. Sin vecinos colocados → aparca en columna
|
// true y escribe (out_x, out_y); false si no hay hueco en los radios
|
||||||
// a la derecha del bbox actual.
|
// disponibles. `seed` se usa para jitter deterministico (ej: user_data).
|
||||||
static void place_orphans_near_neighbors(GraphData& g, float min_dist) {
|
static bool find_collision_free_slot(const GraphData& g, int self_idx,
|
||||||
if (g.node_count == 0) return;
|
float cx, float cy, float min_dist,
|
||||||
const float radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
|
uint64_t seed,
|
||||||
const int n_radii = (int)(sizeof(radii) / sizeof(radii[0]));
|
const float* radii, int n_radii,
|
||||||
|
float* out_x, float* out_y)
|
||||||
|
{
|
||||||
const int slots = 12;
|
const int slots = 12;
|
||||||
const float two_pi = 6.28318530718f;
|
const float two_pi = 6.28318530718f;
|
||||||
const float slot_arc = two_pi / slots;
|
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).
|
// Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto.
|
||||||
float bbox_max_x = 0.0f, bbox_min_y = 0.0f, bbox_max_y = 0.0f;
|
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;
|
bool bbox_init = false;
|
||||||
for (int i = 0; i < g.node_count; ++i) {
|
if (!use_camera) {
|
||||||
const GraphNode& n = g.nodes[i];
|
for (int i = 0; i < g.node_count; ++i) {
|
||||||
if (n.x == 0.0f && n.y == 0.0f) continue;
|
const GraphNode& n = g.nodes[i];
|
||||||
if (!bbox_init) {
|
if (n.x == 0.0f && n.y == 0.0f) continue;
|
||||||
bbox_max_x = n.x; bbox_min_y = n.y; bbox_max_y = n.y;
|
if (!bbox_init) {
|
||||||
bbox_init = true;
|
bbox_max_x = n.x; bbox_min_y = n.y;
|
||||||
} else {
|
bbox_init = true;
|
||||||
if (n.x > bbox_max_x) bbox_max_x = n.x;
|
} else {
|
||||||
if (n.y < bbox_min_y) bbox_min_y = n.y;
|
if (n.x > bbox_max_x) bbox_max_x = n.x;
|
||||||
if (n.y > bbox_max_y) bbox_max_y = n.y;
|
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_x = bbox_init ? bbox_max_x + 120.0f : 0.0f;
|
||||||
float park_y = bbox_init ? bbox_min_y : 0.0f;
|
float park_y = bbox_init ? bbox_min_y : 0.0f;
|
||||||
int park_n = 0;
|
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) {
|
for (int i = 0; i < g.node_count; ++i) {
|
||||||
GraphNode& n = g.nodes[i];
|
GraphNode& n = g.nodes[i];
|
||||||
if (n.x != 0.0f || n.y != 0.0f) continue;
|
if (n.x != 0.0f || n.y != 0.0f) continue;
|
||||||
|
|
||||||
int parent = layout_first_placed_neighbor(g, i);
|
int parent = layout_first_placed_neighbor(g, i);
|
||||||
if (parent < 0) {
|
if (parent >= 0) {
|
||||||
// Sin vecino colocado: aparca en columna lateral, separados
|
float ox, oy;
|
||||||
// verticalmente por min_dist. Si tampoco hay bbox (grafo
|
if (find_collision_free_slot(
|
||||||
// recien creado), columna en (0, 0) hacia abajo.
|
g, i, g.nodes[parent].x, g.nodes[parent].y,
|
||||||
n.x = park_x;
|
min_dist, n.user_data,
|
||||||
n.y = park_y + park_n * min_dist;
|
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
|
||||||
n.vx = 0.0f; n.vy = 0.0f;
|
n.x = ox; n.y = oy;
|
||||||
++park_n; ++parked;
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jitter deterministico por user_data → dos huerfanos del mismo
|
if (use_camera) {
|
||||||
// padre eligen ciclos diferentes y no se solapan.
|
// Sin vecino → colocar dentro de la camara con ring placement.
|
||||||
float jitter = ((float)((n.user_data >> 16) & 0xFF) / 255.0f) * slot_arc;
|
float ox, oy;
|
||||||
|
if (find_collision_free_slot(
|
||||||
bool placed_ok = false;
|
g, i, cam_cx, cam_cy, min_dist, n.user_data,
|
||||||
for (int ri = 0; ri < n_radii && !placed_ok; ++ri) {
|
cam_radii, 6, &ox, &oy)) {
|
||||||
float r = radii[ri];
|
n.x = ox; n.y = oy;
|
||||||
for (int s = 0; s < slots && !placed_ok; ++s) {
|
} else {
|
||||||
float a = jitter + s * slot_arc;
|
// Anillo amplio aceptando solape.
|
||||||
float cx = g.nodes[parent].x + r * std::cos(a);
|
float two_pi = 6.28318530718f;
|
||||||
float cy = g.nodes[parent].y + r * std::sin(a);
|
float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi;
|
||||||
if (layout_no_collision(g, i, cx, cy, min_dist)) {
|
float r = cam_radii[5];
|
||||||
n.x = cx; n.y = cy;
|
n.x = cam_cx + std::cos(a) * r;
|
||||||
n.vx = 0.0f; n.vy = 0.0f;
|
n.y = cam_cy + std::sin(a) * r;
|
||||||
placed_ok = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
n.vx = n.vy = 0.0f;
|
||||||
|
++placed_camera;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (!placed_ok) {
|
|
||||||
// Fallback: pone en el ultimo radio + slot 0 (acepta solape).
|
// Legacy: columna lateral (fuera de cam — usado en first_load).
|
||||||
float r = radii[n_radii - 1];
|
n.x = park_x;
|
||||||
n.x = g.nodes[parent].x + r * std::cos(jitter);
|
n.y = park_y + park_n * min_dist;
|
||||||
n.y = g.nodes[parent].y + r * std::sin(jitter);
|
n.vx = n.vy = 0.0f;
|
||||||
n.vx = 0.0f; n.vy = 0.0f;
|
++park_n; ++parked;
|
||||||
}
|
|
||||||
++placed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placed > 0 || parked > 0) {
|
if (placed_neighbor || placed_camera || parked) {
|
||||||
std::fprintf(stdout,
|
std::fprintf(stdout,
|
||||||
"[graph_explorer] placed %d orphans near neighbors, %d parked in column\n",
|
"[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n",
|
||||||
placed, parked);
|
placed_neighbor, placed_camera, parked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,35 +572,37 @@ static bool load_input(bool first_load) {
|
|||||||
g_viewport.layout_running = false;
|
g_viewport.layout_running = false;
|
||||||
g_viewport.layout_energy = 0.0f;
|
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).
|
// Indice user_data -> sql id (para CRUD desde menu contextual).
|
||||||
ge::entity_index_build(g_input.uri, &g_idx);
|
ge::entity_index_build(g_input.uri, &g_idx);
|
||||||
g_app.input_db_path = g_input.uri ? g_input.uri : "";
|
g_app.input_db_path = g_input.uri ? g_input.uri : "";
|
||||||
|
|
||||||
// issue 0026 — apunta el JobRunner a la nueva operations.db.
|
// issue 0026 — apunta el JobRunner a la nueva operations.db.
|
||||||
if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
|
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);
|
g_graph_hash = ge::compute_graph_hash(g_input.uri);
|
||||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||||
if (restored > 0) {
|
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
|
// Huerfanos (nodos sin posicion guardada): halo placement junto a su
|
||||||
// primer vecino con coordenadas conocidas (issue 0031). En primera carga
|
// primer vecino con coordenadas conocidas (issue 0031). En primera carga
|
||||||
// tambien aplica — si layout_circular ya los puso en circulo, no entran
|
// 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},
|
{"Types", nullptr, &g_app.panel_type_editor},
|
||||||
{"Table", nullptr, &g_app.panel_table},
|
{"Table", nullptr, &g_app.panel_table},
|
||||||
{"Jobs", nullptr, &g_app.panel_jobs},
|
{"Jobs", nullptr, &g_app.panel_jobs},
|
||||||
|
{"Echo", nullptr, &g_app.panel_chat},
|
||||||
};
|
};
|
||||||
|
|
||||||
static void render() {
|
static void render() {
|
||||||
update_fps();
|
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[].
|
// No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].
|
||||||
|
|
||||||
if (!g_loaded) {
|
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
|
// Triggers desde la toolbar
|
||||||
if (g_app.want_fit) {
|
if (g_app.want_fit) {
|
||||||
graph_viewport_fit(g_graph, g_viewport);
|
graph_viewport_fit(g_graph, g_viewport);
|
||||||
@@ -867,20 +1104,37 @@ static void render() {
|
|||||||
ge::views_reset_visibility(g_app);
|
ge::views_reset_visibility(g_app);
|
||||||
ge::views_apply_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.
|
// Restaura posiciones guardadas para nodos preexistentes.
|
||||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||||
(void)restored;
|
(void)restored;
|
||||||
|
|
||||||
// (C) Halo placement: huerfanos creados por enrichers se
|
// Halo placement: junto a vecino con posicion conocida; si no
|
||||||
// colocan junto a su primer vecino con posicion conocida,
|
// tiene vecino (caso tipico cuando el agente crea un nodo
|
||||||
// evitando solapamiento con nodos existentes (issue 0031).
|
// aislado via MCP node_create), DENTRO de la camara visible
|
||||||
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
|
// 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();
|
g_graph.update_bounds();
|
||||||
|
|
||||||
// (B) NO graph_viewport_fit en reloads: preserva camara del
|
// Physics pausadas tras reload (issue 0031).
|
||||||
// usuario (issue 0031).
|
|
||||||
|
|
||||||
// (E) Physics siempre pausadas tras reload (issue 0031).
|
|
||||||
g_viewport.layout_running = false;
|
g_viewport.layout_running = false;
|
||||||
|
|
||||||
// Refresca el indice user_data -> sql id (puede haber nuevos
|
// 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);
|
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||||
(void)restored;
|
(void)restored;
|
||||||
|
|
||||||
// Centro del area visible en world coords (para que los nuevos nodos
|
// Halo placement: prefiere vecino, fallback a la camara con anti-
|
||||||
// aparezcan donde el usuario esta mirando, no en el origen).
|
// colision. Los nodos nuevos aparecen DENTRO de la camara y NO
|
||||||
float cx = -g_viewport.cam_x;
|
// encima de otros — el usuario los ve sin pan/zoom.
|
||||||
float cy = -g_viewport.cam_y;
|
// (cam_x, cam_y) es el world point en el centro de la pantalla.
|
||||||
float spread_r = 80.0f / (g_viewport.zoom > 0.01f ? g_viewport.zoom : 0.01f);
|
float cam_cx = g_viewport.cam_x;
|
||||||
|
float cam_cy = g_viewport.cam_y;
|
||||||
// Reparte los nodos sin posicion en un anillo poisson alrededor del
|
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
|
||||||
// centro visible. Determinista por user_data para que el mismo nodo
|
? g_viewport.zoom : 0.01f);
|
||||||
// caiga siempre en el mismo sitio entre reloads.
|
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
|
||||||
for (int i = 0; i < g_graph.node_count; ++i) {
|
/*use_camera=*/true,
|
||||||
GraphNode& n = g_graph.nodes[i];
|
cam_cx, cam_cy, cam_r);
|
||||||
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;
|
|
||||||
}
|
|
||||||
g_graph.update_bounds();
|
g_graph.update_bounds();
|
||||||
g_atlas_bound = false;
|
g_atlas_bound = false;
|
||||||
g_gpu_dirty = true;
|
g_gpu_dirty = true;
|
||||||
@@ -1480,6 +1726,12 @@ static void render() {
|
|||||||
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
|
||||||
ge::views_jobs(g_app);
|
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;
|
g_first_render = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,6 +1918,8 @@ int main(int argc, char** argv) {
|
|||||||
if (!g_input_path.empty()) {
|
if (!g_input_path.empty()) {
|
||||||
load_input();
|
load_input();
|
||||||
}
|
}
|
||||||
|
panel_state_load_db(g_layout_db_path, g_panels,
|
||||||
|
sizeof(g_panels) / sizeof(g_panels[0]));
|
||||||
} else {
|
} else {
|
||||||
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
|
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
|
||||||
// crea default si no existe ninguno.
|
// 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::layout_store_open(g_layout_db_path.c_str());
|
||||||
ge::project_settings_touch(target.c_str());
|
ge::project_settings_touch(target.c_str());
|
||||||
load_input();
|
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(
|
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()
|
const char* app_db = g_layout_db_path.empty()
|
||||||
? "graph_explorer.db" : g_layout_db_path.c_str();
|
? "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());
|
ge::enrichers_load(enrichers_dir.c_str());
|
||||||
if (!ge::jobs_init(app_db,
|
if (!ge::jobs_init(app_db,
|
||||||
g_input.uri ? g_input.uri : "",
|
g_input.uri ? g_input.uri : "",
|
||||||
@@ -1730,6 +2007,16 @@ int main(int argc, char** argv) {
|
|||||||
enrichers_dir.c_str(), registry_root.c_str(),
|
enrichers_dir.c_str(), registry_root.c_str(),
|
||||||
(int)ge::enrichers_all().size());
|
(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(
|
int rc = fn::run_app(
|
||||||
@@ -1739,11 +2026,29 @@ int main(int argc, char** argv) {
|
|||||||
.viewports = true,
|
.viewports = true,
|
||||||
.panels = g_panels,
|
.panels = g_panels,
|
||||||
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
|
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
|
||||||
|
.layouts_cb = g_layout_storage ? &g_layout_cb : nullptr,
|
||||||
.init_gl_loader = true},
|
.init_gl_loader = true},
|
||||||
render);
|
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
|
// Cleanup
|
||||||
|
ge::chat_shutdown();
|
||||||
ge::jobs_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_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
|
||||||
if (g_atlas) graph_icons_destroy(g_atlas);
|
if (g_atlas) graph_icons_destroy(g_atlas);
|
||||||
graph_viewport_destroy(g_viewport);
|
graph_viewport_destroy(g_viewport);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct AppState {
|
|||||||
bool panel_viewport = true;
|
bool panel_viewport = true;
|
||||||
bool panel_note = false;
|
bool panel_note = false;
|
||||||
bool panel_jobs = false; // issue 0026
|
bool panel_jobs = false; // issue 0026
|
||||||
|
bool panel_chat = false; // claude -p chat (issue 0001)
|
||||||
bool show_filters_modal = false;
|
bool show_filters_modal = false;
|
||||||
bool show_open_modal = false;
|
bool show_open_modal = false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user