#include #include "app_base.h" #include "core/panel_menu.h" #include "core/icons_tabler.h" #include "core/logger.h" #include "core/parse_md_frontmatter.h" #include "core/compute_ring_layout.h" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; // ---- Node model --------------------------------------------------------- enum class NodeKind { Issue, Flow }; struct Node { NodeKind kind; std::string id; std::string title; std::string status_raw; // status as read from frontmatter std::string status_eff; // effective status after lock derivation std::string type; std::string priority; std::vector domain; std::vector depends; // for issues std::vector related; std::string file_path; int mtime_rank = 0; // for recency 0..1 float x = 0.0f; float y = 0.0f; int ring = -1; int sector = 0; // animation float prev_x = 0.0f; float prev_y = 0.0f; double anim_start = 0.0; // seconds since epoch when last status change std::string anim_prev_status; // status when anim_start was set }; struct ScanResult { std::vector nodes; std::map count_by_status; std::map count_by_domain; std::map count_by_kind; int parse_errors = 0; }; // ---- Constants ---------------------------------------------------------- // Canonical domain order from dev/TAXONOMY.md (18 sectors = 18 domains). static const std::vector kDomainOrder = { "meta", "cpp-stack", "kanban", "trading", "gamedev", "osint", "data-ingest", "registry-quality", "notify", "imagegen", "apps-infra", "dev-ux", "deploy", "frontend", "mcp", "browser", "telemetry", "docs", }; // status_eff buckets -> ring index static const fn_ring::StatusRingMap kStatusMap = { {"completado", 0}, {"completed", 0}, {"in-progress", 1}, {"pendiente_unlocked", 2}, {"pendiente_locked", 3}, {"deferred", 4}, {"bloqueado", 4}, }; // Colors per ring (RGBA u32, ABGR memory order for IM_COL32) static ImU32 ring_color(int ring) { switch (ring) { case 0: return IM_COL32( 76, 175, 80, 230); // green completado case 1: return IM_COL32(255, 193, 7, 230); // amber in-progress case 2: return IM_COL32(192, 38, 211, 230); // fuchsia unlocked (accent) case 3: return IM_COL32(120, 120, 130, 180); // gray locked case 4: return IM_COL32( 80, 80, 90, 110); // dim deferred / bloqueado default: return IM_COL32(150, 150, 150, 120); } } static const char* ring_label(int ring) { switch (ring) { case 0: return "done"; case 1: return "in-progress"; case 2: return "unlocked"; case 3: return "locked"; case 4: return "deferred"; default: return "?"; } } // ---- Registry root discovery ------------------------------------------- static fs::path discover_registry_root() { if (const char* env = std::getenv("FN_REGISTRY_ROOT")) { fs::path p(env); if (fs::exists(p / "registry.db")) return p; } for (auto p = fs::current_path(); !p.empty() && p != p.root_path(); p = p.parent_path()) { if (fs::exists(p / "registry.db") && fs::exists(p / "dev" / "issues")) return p; } return {}; } // ---- Helpers ------------------------------------------------------------ static std::string read_file(const fs::path& p) { std::ifstream f(p); if (!f) return {}; std::stringstream ss; ss << f.rdbuf(); return ss.str(); } static std::string get_str(const fn_md::Frontmatter& fm, const char* key) { auto it = fm.fields.find(key); if (it == fm.fields.end()) return {}; if (auto* s = std::get_if(&it->second)) return *s; return {}; } static std::vector get_list(const fn_md::Frontmatter& fm, const char* key) { auto it = fm.fields.find(key); if (it == fm.fields.end()) return {}; if (auto* v = std::get_if>(&it->second)) return *v; if (auto* s = std::get_if(&it->second)) { if (!s->empty()) return { *s }; } return {}; } static double now_seconds() { using namespace std::chrono; return duration_cast>(steady_clock::now().time_since_epoch()).count(); } // ---- Scanner ------------------------------------------------------------ static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) { if (!fs::exists(dir) || !fs::is_directory(dir)) return; for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; const auto& p = entry.path(); if (p.extension() != ".md") continue; auto stem = p.stem().string(); if (stem == "template" || stem == "INDEX" || stem == "README" || stem == "AGENT_GUIDE") continue; auto content = read_file(p); auto fm = fn_md::parse_md_frontmatter(content); if (!fm.has_frontmatter) { ++out.parse_errors; continue; } Node n; n.kind = kind; n.id = get_str(fm, "id"); if (n.id.empty()) n.id = get_str(fm, "name"); n.title = get_str(fm, kind == NodeKind::Issue ? "title" : "name"); n.status_raw = get_str(fm, "status"); n.type = get_str(fm, "type"); n.priority = get_str(fm, "priority"); n.domain = get_list(fm, "domain"); n.depends = get_list(fm, kind == NodeKind::Issue ? "depends" : "related_issues"); n.related = get_list(fm, "related"); n.file_path = p.string(); if (n.status_raw.empty()) n.status_raw = "(unknown)"; if (n.domain.empty()) n.domain.push_back("(unknown)"); out.nodes.push_back(std::move(n)); } } static ScanResult scan_registry(const fs::path& root) { ScanResult r; scan_dir(root / "dev" / "issues", NodeKind::Issue, r); scan_dir(root / "dev" / "issues" / "completed", NodeKind::Issue, r); scan_dir(root / "dev" / "flows", NodeKind::Flow, r); scan_dir(root / "dev" / "flows" / "completed", NodeKind::Flow, r); return r; } // ---- Lock-unlock derivation -------------------------------------------- static void derive_status_eff(std::vector& nodes) { // Build set of "done" IDs (completado / completed). std::unordered_set done; for (const auto& n : nodes) { if (n.status_raw == "completado" || n.status_raw == "completed") done.insert(n.id); } for (auto& n : nodes) { const auto& s = n.status_raw; if (s == "completado" || s == "completed") { n.status_eff = "completado"; } else if (s == "in-progress") { n.status_eff = "in-progress"; } else if (s == "deferred") { n.status_eff = "deferred"; } else if (s == "bloqueado") { n.status_eff = "bloqueado"; } else if (s == "pendiente" || s == "pending") { // Locked if any depends/related_issues is NOT in done. bool locked = false; for (const auto& d : n.depends) { if (!d.empty() && done.find(d) == done.end()) { locked = true; break; } } n.status_eff = locked ? "pendiente_locked" : "pendiente_unlocked"; } else { n.status_eff = "deferred"; // unknown bucket -> outer ring } } } // ---- UI state ----------------------------------------------------------- static fs::path g_root; static ScanResult g_scan; static bool g_show_tree = true; static bool g_show_inspector = true; static int g_selected = -1; static int g_hover = -1; static float g_cam_x = 0.0f; static float g_cam_y = 0.0f; static float g_cam_zoom = 1.0f; static const float kNodeRadius = 10.0f; static const float kFlowRadiusMul = 1.55f; static const float kAnimDur = 1.0f; // seconds for ring migration lerp static const float kWorldExtent = 1200.0f * 2.0f; // diameter to fit static const std::vector kRingRadii = { 0.0f, 200.0f, 380.0f, 600.0f, 900.0f, 1200.0f }; static bool g_fit_pending = true; // auto-fit on first frame // Apply ring layout to nodes, preserving anim state across reloads. static void apply_layout(std::vector& nodes) { std::vector input; input.reserve(nodes.size()); for (const auto& n : nodes) { fn_ring::LayoutInput li; li.id = n.id; li.status = n.status_eff; li.domain = n.domain.empty() ? "(unknown)" : n.domain.front(); li.recency = 0.0f; input.push_back(std::move(li)); } fn_ring::LayoutConfig cfg; cfg.n_sectors = 18; cfg.center_x = 0.0f; cfg.center_y = 0.0f; cfg.ring_radii = kRingRadii; cfg.bin_padding = 28.0f; cfg.start_angle = -1.5708f; // -PI/2: sector 0 starts at 12 o'clock auto out = fn_ring::compute_ring_layout(input, cfg, kStatusMap, kDomainOrder); // Map by id for O(1) assignment. std::unordered_map by_id; by_id.reserve(out.size()); for (const auto& o : out) by_id.emplace(o.id, &o); double now = now_seconds(); for (auto& n : nodes) { auto it = by_id.find(n.id); if (it == by_id.end()) continue; const auto& o = *it->second; // Detect status change since last layout: kick off anim. if (!n.anim_prev_status.empty() && n.anim_prev_status != n.status_eff) { n.prev_x = n.x; n.prev_y = n.y; n.anim_start = now; } if (n.anim_prev_status.empty()) { // first apply: no anim needed n.prev_x = o.x; n.prev_y = o.y; n.anim_start = now - kAnimDur; // already finished } n.x = o.x; n.y = o.y; n.ring = o.ring; n.sector = o.sector; n.anim_prev_status = n.status_eff; } } static void recount(ScanResult& s) { s.count_by_status.clear(); s.count_by_domain.clear(); s.count_by_kind.clear(); for (const auto& n : s.nodes) { ++s.count_by_status[n.status_eff]; ++s.count_by_kind[n.kind == NodeKind::Issue ? "issue" : "flow"]; for (const auto& d : n.domain) ++s.count_by_domain[d]; } } static void reload_scan() { auto fresh = scan_registry(g_root); derive_status_eff(fresh.nodes); // Preserve animation state by id across reload. std::unordered_map prev_by_id; for (auto& n : g_scan.nodes) prev_by_id.emplace(n.id, std::move(n)); for (auto& n : fresh.nodes) { auto it = prev_by_id.find(n.id); if (it != prev_by_id.end()) { n.prev_x = it->second.x; n.prev_y = it->second.y; n.anim_start = it->second.anim_start; n.anim_prev_status = it->second.anim_prev_status; } } g_scan = std::move(fresh); apply_layout(g_scan.nodes); recount(g_scan); g_selected = -1; fn_log::log_info("skill_tree: reloaded %d nodes, %d parse errors", (int)g_scan.nodes.size(), g_scan.parse_errors); } // ---- Canvas drawing ----------------------------------------------------- static ImVec2 world_to_screen(const ImVec2& origin, float wx, float wy) { return ImVec2(origin.x + wx * g_cam_zoom + g_cam_x, origin.y + wy * g_cam_zoom + g_cam_y); } static void draw_canvas() { const ImVec2 avail = ImGui::GetContentRegionAvail(); if (avail.x < 40 || avail.y < 40) return; ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight); const bool hovered = ImGui::IsItemHovered(); const bool active = ImGui::IsItemActive(); const ImVec2 p0 = ImGui::GetItemRectMin(); const ImVec2 p1 = ImGui::GetItemRectMax(); const ImVec2 center(0.5f * (p0.x + p1.x), 0.5f * (p0.y + p1.y)); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->PushClipRect(p0, p1, true); // Background. dl->AddRectFilled(p0, p1, IM_COL32(18, 18, 22, 255)); // Pan with right or middle drag. if (active && ImGui::IsMouseDragging(ImGuiMouseButton_Right, 0.0f)) { ImVec2 d = ImGui::GetIO().MouseDelta; g_cam_x += d.x; g_cam_y += d.y; } if (active && ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 0.0f)) { ImVec2 d = ImGui::GetIO().MouseDelta; g_cam_x += d.x; g_cam_y += d.y; } // Zoom with wheel (centered on mouse). if (hovered && std::abs(ImGui::GetIO().MouseWheel) > 0.0f) { const float wheel = ImGui::GetIO().MouseWheel; const float old_zoom = g_cam_zoom; const float new_zoom = std::clamp(old_zoom * (1.0f + wheel * 0.12f), 0.2f, 4.0f); // Keep mouse-world point under cursor: ImVec2 mp = ImGui::GetMousePos(); float mx_world = (mp.x - center.x - g_cam_x) / old_zoom; float my_world = (mp.y - center.y - g_cam_y) / old_zoom; g_cam_zoom = new_zoom; g_cam_x = (mp.x - center.x) - mx_world * new_zoom; g_cam_y = (mp.y - center.y) - my_world * new_zoom; } // Origin honoring pan; concentric backdrop rings. ImVec2 origin_with_pan(center.x + g_cam_x, center.y + g_cam_y); // Auto-fit on first frame (after avail is known): scale so outer ring fits with margin. if (g_fit_pending) { float min_dim = std::min(avail.x, avail.y); g_cam_zoom = std::clamp((min_dim * 0.92f) / kWorldExtent, 0.05f, 4.0f); g_cam_x = 0.0f; g_cam_y = 0.0f; g_fit_pending = false; } // Ring band fills with subtle tint by status. static const ImU32 ring_band_tint[5] = { IM_COL32( 30, 60, 30, 40), // done IM_COL32( 60, 50, 20, 40), // in-progress IM_COL32( 60, 20, 60, 40), // unlocked IM_COL32( 32, 32, 40, 40), // locked IM_COL32( 20, 20, 24, 40), // deferred }; for (int i = 0; i < (int)kRingRadii.size() - 1; ++i) { float ri = kRingRadii[i] * g_cam_zoom; float ro = kRingRadii[i + 1] * g_cam_zoom; // Filled annulus via two circles (approximate); ImDrawList lacks ring fill. // Trick: filled circle outer alpha + cut center with inner. Use AddCircleFilled twice // — outer in tint, inner in bg to subtract. Cheaper: thick line outline + tinted bg. dl->AddCircleFilled(origin_with_pan, ro, ring_band_tint[i], 96); if (ri > 0.5f) dl->AddCircleFilled(origin_with_pan, ri, IM_COL32(18, 18, 22, 255), 96); } // Ring outlines. for (int i = 1; i < (int)kRingRadii.size(); ++i) { dl->AddCircle(origin_with_pan, kRingRadii[i] * g_cam_zoom, IM_COL32(255, 255, 255, 30), 96, 1.0f); } // Center marker. dl->AddCircleFilled(origin_with_pan, 3.0f, IM_COL32(255, 255, 255, 80)); // Build screen-space index for picking. const double now = now_seconds(); auto node_screen = [&](const Node& n) { float t = std::clamp(float(now - n.anim_start) / kAnimDur, 0.0f, 1.0f); // ease-in-out float e = t * t * (3.0f - 2.0f * t); float wx = n.prev_x + (n.x - n.prev_x) * e; float wy = n.prev_y + (n.y - n.prev_y) * e; return ImVec2(origin_with_pan.x + wx * g_cam_zoom, origin_with_pan.y + wy * g_cam_zoom); }; // Edges: depends + related as thin lines between ID-matched nodes. std::unordered_map idx_by_id; idx_by_id.reserve(g_scan.nodes.size()); for (int i = 0; i < (int)g_scan.nodes.size(); ++i) idx_by_id[g_scan.nodes[i].id] = i; auto draw_edge = [&](int src, int dst, ImU32 col) { if (src < 0 || dst < 0) return; ImVec2 a = node_screen(g_scan.nodes[src]); ImVec2 b = node_screen(g_scan.nodes[dst]); // Curve toward center. ImVec2 mid((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f); ImVec2 toCenter(origin_with_pan.x - mid.x, origin_with_pan.y - mid.y); float len = std::sqrt(toCenter.x * toCenter.x + toCenter.y * toCenter.y); if (len > 0.001f) { toCenter.x /= len; toCenter.y /= len; } ImVec2 c1(mid.x + toCenter.x * 40.0f * g_cam_zoom, mid.y + toCenter.y * 40.0f * g_cam_zoom); dl->AddBezierQuadratic(a, c1, b, col, 1.2f); }; for (int i = 0; i < (int)g_scan.nodes.size(); ++i) { const auto& n = g_scan.nodes[i]; for (const auto& d : n.depends) { auto it = idx_by_id.find(d); if (it != idx_by_id.end()) draw_edge(it->second, i, IM_COL32(160, 160, 180, 90)); } for (const auto& r : n.related) { auto it = idx_by_id.find(r); if (it != idx_by_id.end()) draw_edge(it->second, i, IM_COL32(120, 100, 180, 50)); } } // Picking + node draw. Two passes: issues first (background), flows on top. g_hover = -1; const ImVec2 mp = ImGui::GetMousePos(); const float node_r_issue = kNodeRadius * g_cam_zoom; const float node_r_flow = kNodeRadius * kFlowRadiusMul * g_cam_zoom; auto draw_one = [&](int i, bool flow_pass) { const auto& n = g_scan.nodes[i]; if (n.ring < 0) return; bool is_flow = (n.kind == NodeKind::Flow); if (is_flow != flow_pass) return; ImVec2 sp = node_screen(n); const float r = is_flow ? node_r_flow : node_r_issue; if (sp.x < p0.x - r || sp.x > p1.x + r) return; if (sp.y < p0.y - r || sp.y > p1.y + r) return; float dx = mp.x - sp.x, dy = mp.y - sp.y; bool over = hovered && (dx * dx + dy * dy) < r * r; if (over) g_hover = i; ImU32 col = ring_color(n.ring); if (is_flow) { // Diamond + thick cyan outline so flows pop out of the issue sea. ImVec2 pts[4] = { { sp.x, sp.y - r }, { sp.x + r, sp.y }, { sp.x, sp.y + r }, { sp.x - r, sp.y }, }; dl->AddConvexPolyFilled(pts, 4, col); ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255) : IM_COL32(34, 211, 238, 255); // cyan-400 always dl->AddPolyline(pts, 4, outline, ImDrawFlags_Closed, (g_selected == i) ? 3.0f : 2.0f); } else { dl->AddCircleFilled(sp, r, col, 20); ImU32 outline = (g_selected == i) ? IM_COL32(255, 255, 255, 255) : (over ? IM_COL32(255, 255, 255, 220) : IM_COL32(255, 255, 255, 90)); dl->AddCircle(sp, r, outline, 20, g_selected == i ? 2.5f : 1.0f); } // Text only when zoomed in or this is a flow (always show flow labels). if ((g_cam_zoom > 0.65f) || is_flow) { const char* lbl = n.id.c_str(); ImVec2 ts = ImGui::CalcTextSize(lbl); ImVec2 tp(sp.x - ts.x * 0.5f, sp.y - ts.y * 0.5f); dl->AddText(ImVec2(tp.x + 1, tp.y + 1), IM_COL32(0, 0, 0, 220), lbl); dl->AddText(tp, IM_COL32(255, 255, 255, 245), lbl); } // Tooltip on hover (title). if (over) { ImGui::BeginTooltip(); ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str()); ImGui::TextWrapped("%s", n.title.c_str()); ImGui::TextDisabled("status: %s · ring: %s · domain: %s", n.status_eff.c_str(), ring_label(n.ring), n.domain.empty() ? "?" : n.domain.front().c_str()); ImGui::EndTooltip(); } }; // Pass 1: issues. Pass 2: flows on top. for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, false); for (int i = 0; i < (int)g_scan.nodes.size(); ++i) draw_one(i, true); // Click: select. if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && g_hover >= 0) { g_selected = g_hover; } // Sector labels at outermost ring. if (g_cam_zoom > 0.4f) { const int N = 18; const float r = kRingRadii.back() - 12.0f; for (int s = 0; s < N; ++s) { float theta = -1.5708f + (s + 0.5f) * (2.0f * 3.14159265f / N); ImVec2 sp(origin_with_pan.x + std::cos(theta) * r * g_cam_zoom, origin_with_pan.y + std::sin(theta) * r * g_cam_zoom); const std::string& dom = (s < (int)kDomainOrder.size()) ? kDomainOrder[s] : std::string("(other)"); ImVec2 ts = ImGui::CalcTextSize(dom.c_str()); dl->AddText(ImVec2(sp.x - ts.x * 0.5f, sp.y - ts.y * 0.5f), IM_COL32(255, 255, 255, 110), dom.c_str()); } } dl->PopClipRect(); } // ---- Panels ------------------------------------------------------------- static void draw_tree() { if (!ImGui::Begin(TI_GRAPH " Tree", &g_show_tree)) { ImGui::End(); return; } // HUD strip. int n_done = g_scan.count_by_status["completado"]; int n_inprog = g_scan.count_by_status["in-progress"]; int n_unlock = g_scan.count_by_status["pendiente_unlocked"]; int n_lock = g_scan.count_by_status["pendiente_locked"]; int n_defer = g_scan.count_by_status["deferred"] + g_scan.count_by_status["bloqueado"]; int xp_total = 10 * n_done; // crude: tune later (epic=10 etc.) int level = (int)std::sqrt((float)xp_total); ImGui::Text("LV %d · XP %d · %d done · %d in-progress · %d unlocked · %d locked · %d deferred", level, xp_total, n_done, n_inprog, n_unlock, n_lock, n_defer); ImGui::SameLine(); if (ImGui::SmallButton(TI_REFRESH " Reload (F5)")) reload_scan(); if (ImGui::IsKeyPressed(ImGuiKey_F5, false)) reload_scan(); ImGui::SameLine(); if (ImGui::SmallButton("Fit view")) { g_fit_pending = true; } ImGui::Separator(); draw_canvas(); ImGui::End(); } static void draw_inspector() { if (!ImGui::Begin(TI_INFO_CIRCLE " Inspector", &g_show_inspector)) { ImGui::End(); return; } if (g_selected < 0 || g_selected >= (int)g_scan.nodes.size()) { ImGui::TextDisabled("Click en un nodo del Tree para inspeccionar."); ImGui::End(); return; } const auto& n = g_scan.nodes[g_selected]; ImGui::Text("%s %s", n.kind == NodeKind::Issue ? "[ISSUE]" : "[FLOW]", n.id.c_str()); ImGui::TextWrapped("%s", n.title.c_str()); ImGui::Separator(); ImGui::Text("status: %s (eff: %s)", n.status_raw.c_str(), n.status_eff.c_str()); ImGui::Text("ring: %s", ring_label(n.ring)); if (!n.type.empty()) ImGui::Text("type: %s", n.type.c_str()); if (!n.priority.empty()) ImGui::Text("priority: %s", n.priority.c_str()); ImGui::Text("file: %s", n.file_path.c_str()); ImGui::SeparatorText("domain"); for (const auto& d : n.domain) ImGui::BulletText("%s", d.c_str()); if (!n.depends.empty()) { ImGui::SeparatorText(n.kind == NodeKind::Issue ? "depends" : "related_issues"); for (const auto& d : n.depends) ImGui::BulletText("%s", d.c_str()); } if (!n.related.empty()) { ImGui::SeparatorText("related"); for (const auto& r : n.related) ImGui::BulletText("%s", r.c_str()); } ImGui::Separator(); ImGui::TextDisabled("Botones [Generate ideas] / [Run autonomous-task] llegan en 0109e/f."); ImGui::End(); } static void render() { if (g_show_tree) draw_tree(); if (g_show_inspector) draw_inspector(); } // ---- Self-test ---------------------------------------------------------- static int run_self_test() { g_root = discover_registry_root(); if (g_root.empty()) { std::fprintf(stderr, "skill_tree --self-test: FN_REGISTRY_ROOT not found\n"); return 1; } g_scan = scan_registry(g_root); derive_status_eff(g_scan.nodes); apply_layout(g_scan.nodes); recount(g_scan); std::printf("skill_tree v0.2.0\n"); std::printf("root: %s\n", g_root.string().c_str()); std::printf("nodes: %zu (%d issues + %d flows)\n", g_scan.nodes.size(), g_scan.count_by_kind["issue"], g_scan.count_by_kind["flow"]); std::printf("parse_errors: %d\n", g_scan.parse_errors); // Ring breakdown. int per_ring[5] = {0}; int unmapped = 0; for (const auto& n : g_scan.nodes) { if (n.ring < 0 || n.ring >= 5) ++unmapped; else ++per_ring[n.ring]; } std::printf("by_ring: done=%d in-progress=%d unlocked=%d locked=%d deferred=%d unmapped=%d\n", per_ring[0], per_ring[1], per_ring[2], per_ring[3], per_ring[4], unmapped); std::printf("by_status_eff:\n"); for (auto& [k, v] : g_scan.count_by_status) std::printf(" %-22s %d\n", k.c_str(), v); return (g_scan.parse_errors == 0 && unmapped == 0) ? 0 : 2; } // ---- Entry -------------------------------------------------------------- int main(int argc, char** argv) { for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--self-test") == 0) return run_self_test(); } g_root = discover_registry_root(); reload_scan(); static fn_ui::PanelToggle panels[] = { { "Tree", nullptr, &g_show_tree }, { "Inspector", nullptr, &g_show_inspector }, }; fn::AppConfig cfg; cfg.title = "skill_tree"; cfg.about = { "skill_tree", "0.2.0", "Mapa interactivo de issues+flows en anillos concentricos por estado." }; cfg.log = { "skill_tree.log", 1 }; cfg.panels = panels; cfg.panel_count = sizeof(panels) / sizeof(panels[0]); return fn::run_app(cfg, render); }