9ee3be8e4e
- Anade boton 'Launch workflow' (TI_ROCKET) que hace POST async a http://localhost:8486/api/runs con {issue_id, mode:'fix-issue'}. - HTTP async via std::thread + fn_http::request (de http_request_cpp_core) con timeout 3s. NO bloquea el frame. - Feature flag 'legacy_claude_fix' (default OFF) controla la visibilidad del boton 'Claude fix' legacy (terminal externa + claude --dangerously- skip-permissions). Flag leido al arrancar desde dev/feature_flags.json. - Toast 3s con run_id devuelto por el API (o error si :8486 down / transport fail / HTTP non-2xx). Render thread-safe con mutex. - CMakeLists.txt linkea cpp/functions/core/http_request.cpp. - app.md: version 0.1.0 -> 0.2.0, uses_functions anade http_request_cpp_core, capability growth log con entrada v0.2.0.
1603 lines
64 KiB
C++
1603 lines
64 KiB
C++
#include <imgui.h>
|
|
#ifdef _WIN32
|
|
# define WIN32_LEAN_AND_MEAN
|
|
# include <windows.h>
|
|
#endif
|
|
#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 "core/http_request.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
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<std::string> domain;
|
|
std::vector<std::string> depends; // for issues
|
|
std::vector<std::string> 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<Node> nodes;
|
|
std::map<std::string, int> count_by_status;
|
|
std::map<std::string, int> count_by_domain;
|
|
std::map<std::string, int> count_by_kind;
|
|
int parse_errors = 0;
|
|
};
|
|
|
|
// ---- Constants ----------------------------------------------------------
|
|
|
|
// Canonical domain order from dev/TAXONOMY.md (18 sectors = 18 domains).
|
|
static const std::vector<std::string> 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},
|
|
};
|
|
|
|
// 3 buckets de color: done / planned / todo. Independiente del ring (que
|
|
// sigue agrupando geograficamente). El usuario pidio simplificar.
|
|
enum class Bucket { Done, Planned, Todo };
|
|
|
|
static Bucket status_bucket(const std::string& status_eff) {
|
|
if (status_eff == "completado" || status_eff == "completed") return Bucket::Done;
|
|
if (status_eff == "in-progress") return Bucket::Planned;
|
|
return Bucket::Todo;
|
|
}
|
|
|
|
static ImU32 bucket_color(Bucket b) {
|
|
switch (b) {
|
|
case Bucket::Done: return IM_COL32( 34, 197, 94, 240); // green-500
|
|
case Bucket::Planned: return IM_COL32(245, 158, 11, 240); // amber-500
|
|
case Bucket::Todo: return IM_COL32(168, 85, 247, 240); // violet-500
|
|
}
|
|
return IM_COL32(150, 150, 150, 200);
|
|
}
|
|
|
|
static const char* bucket_label(Bucket b) {
|
|
switch (b) {
|
|
case Bucket::Done: return "done";
|
|
case Bucket::Planned: return "planned";
|
|
case Bucket::Todo: return "todo";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
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() {
|
|
// 1. env var
|
|
if (const char* env = std::getenv("FN_REGISTRY_ROOT")) {
|
|
fs::path p(env);
|
|
if (fs::exists(p / "registry.db")) return p;
|
|
}
|
|
// 2. walk up desde cwd
|
|
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;
|
|
}
|
|
#ifdef _WIN32
|
|
// 3. fallback Windows: acceso UNC al sistema de archivos WSL. La ruta del
|
|
// distro/usuario se puede sobreescribir con WSL_REGISTRY_PATH para otros
|
|
// setups; defaults asumen Ubuntu + lucas (este PC).
|
|
const char* unc_env = std::getenv("WSL_REGISTRY_PATH");
|
|
std::vector<std::string> candidates;
|
|
if (unc_env && *unc_env) candidates.push_back(unc_env);
|
|
// Default WSL distros to probe (orden por probabilidad en este setup).
|
|
const char* distros[] = { "Ubuntu-22.04", "Ubuntu", "kali-linux", "Debian" };
|
|
for (const char* d : distros) {
|
|
candidates.push_back(std::string("\\\\wsl.localhost\\") + d + "\\home\\lucas\\fn_registry");
|
|
candidates.push_back(std::string("\\\\wsl$\\") + d + "\\home\\lucas\\fn_registry");
|
|
}
|
|
for (const auto& c : candidates) {
|
|
fs::path p(c);
|
|
std::error_code ec;
|
|
if (fs::exists(p / "registry.db", ec) && fs::exists(p / "dev" / "issues", ec)) {
|
|
return p;
|
|
}
|
|
}
|
|
#endif
|
|
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<std::string>(&it->second)) return *s;
|
|
return {};
|
|
}
|
|
|
|
static std::vector<std::string> 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<std::vector<std::string>>(&it->second)) return *v;
|
|
if (auto* s = std::get_if<std::string>(&it->second)) {
|
|
if (!s->empty()) return { *s };
|
|
}
|
|
return {};
|
|
}
|
|
|
|
static double now_seconds() {
|
|
using namespace std::chrono;
|
|
return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// Lanza terminal externa con `claude --dangerously-skip-permissions` en el
|
|
// repo fn_registry. En Windows usa Windows Terminal + WSL; en Linux usa el
|
|
// primer emulador disponible. Fire-and-forget; no captura output.
|
|
static bool spawn_claude_terminal(const fs::path& registry_root) {
|
|
#ifdef _WIN32
|
|
// wt.exe lanza Windows Terminal. wsl.exe --cd cambia cwd antes del comando.
|
|
// Bash -ic para que /etc/profile cargue PATH (claude esta en ~/.local/bin
|
|
// o equivalente). Sin comillas el resto del comando.
|
|
std::string cmd = "wt.exe new-tab wsl.exe --cd ~/fn_registry -- bash -ic \"claude --dangerously-skip-permissions\"";
|
|
STARTUPINFOA si{}; si.cb = sizeof(si);
|
|
PROCESS_INFORMATION pi{};
|
|
std::string mutable_cmd = cmd; // CreateProcessA needs non-const
|
|
BOOL ok = CreateProcessA(nullptr, mutable_cmd.data(), nullptr, nullptr, FALSE,
|
|
CREATE_NEW_CONSOLE, nullptr, nullptr, &si, &pi);
|
|
if (!ok) {
|
|
// Fallback: cmd.exe + start wt.exe (less reliable but works without explicit path).
|
|
std::string fb = "cmd.exe /C start \"\" wt.exe wsl.exe --cd ~/fn_registry -- bash -ic \"claude --dangerously-skip-permissions\"";
|
|
mutable_cmd = fb;
|
|
ok = CreateProcessA(nullptr, mutable_cmd.data(), nullptr, nullptr, FALSE,
|
|
CREATE_NEW_CONSOLE, nullptr, nullptr, &si, &pi);
|
|
}
|
|
if (ok) {
|
|
CloseHandle(pi.hProcess);
|
|
CloseHandle(pi.hThread);
|
|
return true;
|
|
}
|
|
return false;
|
|
#else
|
|
// Linux: probar varios emuladores comunes.
|
|
const std::string cd = "cd '" + registry_root.string() + "' && exec claude --dangerously-skip-permissions";
|
|
const char* candidates[] = {
|
|
"x-terminal-emulator", "gnome-terminal", "konsole", "xterm", "alacritty", "kitty",
|
|
};
|
|
for (const char* term : candidates) {
|
|
std::string probe = std::string("command -v ") + term + " >/dev/null 2>&1";
|
|
int probe_rc = std::system(probe.c_str());
|
|
if (probe_rc != 0) continue;
|
|
std::string cmd;
|
|
if (std::string(term) == "gnome-terminal") {
|
|
cmd = std::string(term) + " -- bash -ic \"" + cd + "\" &";
|
|
} else {
|
|
cmd = std::string(term) + " -e bash -ic \"" + cd + "\" &";
|
|
}
|
|
(void)!std::system(cmd.c_str());
|
|
return true;
|
|
}
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
// ---- Feature flag (legacy_claude_fix) -----------------------------------
|
|
//
|
|
// Reads dev/feature_flags.json once at startup, caches the boolean. Minimal
|
|
// substring parser (no JSON library dependency at this scope — keeps the
|
|
// boot path tiny). Looks for "legacy_claude_fix" followed by "enabled":true
|
|
// inside the same object block. Defaults to false on any parse hiccup.
|
|
|
|
static bool g_legacy_claude_fix_flag = false;
|
|
|
|
static void load_feature_flags(const fs::path& registry_root) {
|
|
fs::path p = registry_root / "dev" / "feature_flags.json";
|
|
std::ifstream f(p);
|
|
if (!f.is_open()) {
|
|
g_legacy_claude_fix_flag = false;
|
|
return;
|
|
}
|
|
std::stringstream ss; ss << f.rdbuf();
|
|
std::string s = ss.str();
|
|
auto k = s.find("\"legacy_claude_fix\"");
|
|
if (k == std::string::npos) { g_legacy_claude_fix_flag = false; return; }
|
|
// Scan forward until the next closing brace; look for "enabled": true.
|
|
auto end = s.find('}', k);
|
|
if (end == std::string::npos) end = s.size();
|
|
auto en = s.find("\"enabled\"", k);
|
|
if (en == std::string::npos || en > end) { g_legacy_claude_fix_flag = false; return; }
|
|
auto colon = s.find(':', en);
|
|
if (colon == std::string::npos) { g_legacy_claude_fix_flag = false; return; }
|
|
// Skip whitespace and check for "true".
|
|
size_t i = colon + 1;
|
|
while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i;
|
|
g_legacy_claude_fix_flag = (i + 4 <= s.size() && s.compare(i, 4, "true") == 0);
|
|
}
|
|
|
|
// ---- Launch workflow (agent_runner_api POST) ----------------------------
|
|
//
|
|
// Async HTTP POST to http://localhost:8486/api/runs with
|
|
// { "issue_id": "<id>", "mode": "fix-issue" }. Toast carries the resulting
|
|
// run_id (or error) for 3 seconds. Uses fn_http::request on a detached
|
|
// std::thread so the ImGui frame is never blocked.
|
|
|
|
struct LaunchToast {
|
|
std::mutex mu;
|
|
std::string text; // human-readable message
|
|
bool ok = true;
|
|
double expires_at = -1.0; // seconds since steady_clock epoch
|
|
};
|
|
|
|
static LaunchToast g_launch_toast;
|
|
|
|
static void show_launch_toast(const std::string& msg, bool ok, double ttl_s = 3.0) {
|
|
std::lock_guard<std::mutex> lk(g_launch_toast.mu);
|
|
g_launch_toast.text = msg;
|
|
g_launch_toast.ok = ok;
|
|
g_launch_toast.expires_at = now_seconds() + ttl_s;
|
|
}
|
|
|
|
static std::string extract_json_string_field(const std::string& body, const std::string& key) {
|
|
std::string needle = "\"" + key + "\"";
|
|
auto k = body.find(needle);
|
|
if (k == std::string::npos) return {};
|
|
auto colon = body.find(':', k);
|
|
if (colon == std::string::npos) return {};
|
|
auto q1 = body.find('"', colon + 1);
|
|
if (q1 == std::string::npos) return {};
|
|
auto q2 = body.find('"', q1 + 1);
|
|
if (q2 == std::string::npos) return {};
|
|
return body.substr(q1 + 1, q2 - q1 - 1);
|
|
}
|
|
|
|
static void launch_workflow_async(const std::string& issue_id) {
|
|
show_launch_toast(std::string(TI_LOADER " launching workflow for ") + issue_id + "...", true, 30.0);
|
|
std::thread([issue_id]() {
|
|
fn_http::Request req;
|
|
req.method = "POST";
|
|
req.url = "http://localhost:8486/api/runs";
|
|
req.headers.push_back({"Content-Type", "application/json"});
|
|
// Minimal hand-rolled JSON — both fields are simple ASCII slugs.
|
|
req.body = "{\"issue_id\":\"" + issue_id + "\",\"mode\":\"fix-issue\"}";
|
|
req.timeout_ms = 3000;
|
|
fn_http::Response resp = fn_http::request(req);
|
|
if (resp.status == 0) {
|
|
show_launch_toast(std::string("agent_runner_api unreachable: ") + resp.error, false);
|
|
fn_log::log_warn("skill_tree: launch_workflow %s -> transport error: %s",
|
|
issue_id.c_str(), resp.error.c_str());
|
|
return;
|
|
}
|
|
if (resp.status >= 200 && resp.status < 300) {
|
|
std::string run_id = extract_json_string_field(resp.body, "run_id");
|
|
if (run_id.empty()) run_id = extract_json_string_field(resp.body, "id");
|
|
std::string msg = "run_id=" + (run_id.empty() ? std::string("(unknown)") : run_id);
|
|
show_launch_toast(msg, true);
|
|
fn_log::log_info("skill_tree: launch_workflow %s -> %s", issue_id.c_str(), msg.c_str());
|
|
} else {
|
|
char buf[128]; std::snprintf(buf, sizeof(buf), "HTTP %d", resp.status);
|
|
show_launch_toast(std::string(buf) + ": " + resp.body.substr(0, 80), false);
|
|
fn_log::log_warn("skill_tree: launch_workflow %s -> %s body=%s",
|
|
issue_id.c_str(), buf, resp.body.c_str());
|
|
}
|
|
}).detach();
|
|
}
|
|
|
|
// ---- 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<Node>& nodes) {
|
|
// Build set of "done" IDs (completado / completed).
|
|
std::unordered_set<std::string> 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Draft (ghost-node) model -------------------------------------------
|
|
|
|
struct DraftNode {
|
|
std::string id; // "tmp_<n>"
|
|
std::string source_id; // id del nodo que la genero
|
|
std::string title;
|
|
std::string description;
|
|
std::string proposed_type; // "issue" | "flow"
|
|
std::string proposed_domain; // primer dominio sugerido
|
|
std::string proposed_priority; // alta|media|baja
|
|
std::vector<std::string> proposed_dod;
|
|
|
|
// posicion actual del ghost, animada desde source hasta target ring/sector.
|
|
float src_x = 0, src_y = 0;
|
|
float tgt_x = 0, tgt_y = 0;
|
|
double spawn_t = 0; // anim start
|
|
bool anim_done = false; // tras 1.4s deja de moverse
|
|
};
|
|
|
|
// ---- UI state -----------------------------------------------------------
|
|
|
|
static fs::path g_root;
|
|
static ScanResult g_scan;
|
|
|
|
// Drafts viven en memoria hasta promote o discard. NO se persisten.
|
|
static std::vector<DraftNode> g_drafts;
|
|
static int g_next_draft_n = 1;
|
|
|
|
// Tagged selection: Node (index in g_scan.nodes) o Draft (index in g_drafts).
|
|
enum class SelKind { None, Node, Draft };
|
|
static SelKind g_sel_kind = SelKind::None;
|
|
static int g_sel_index = -1;
|
|
|
|
static bool g_show_tree = true;
|
|
static bool g_show_inspector = true;
|
|
static bool g_show_dashboard = true;
|
|
static int g_hover = -1;
|
|
static SelKind g_hover_kind = SelKind::None;
|
|
static float g_cam_x = 0.0f;
|
|
static float g_cam_y = 0.0f;
|
|
static float g_cam_zoom = 1.0f;
|
|
|
|
static uint32_t fnv1a(const std::string& s) {
|
|
uint32_t h = 2166136261u;
|
|
for (unsigned char c : s) { h ^= c; h *= 16777619u; }
|
|
return h;
|
|
}
|
|
|
|
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<float> 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<Node>& nodes) {
|
|
// Set of valid domains for fast lookup.
|
|
std::unordered_set<std::string> domain_set(kDomainOrder.begin(), kDomainOrder.end());
|
|
|
|
std::vector<fn_ring::LayoutInput> input;
|
|
input.reserve(nodes.size());
|
|
for (const auto& n : nodes) {
|
|
fn_ring::LayoutInput li;
|
|
li.id = n.id;
|
|
li.status = n.status_eff;
|
|
// Domain: usar el primero del frontmatter si esta en la allowlist; si no,
|
|
// distribuir deterministicamente por hash entre los 18 sectores canonicos
|
|
// (evita que todos los "(unknown)" se amontonen en el sector fallback).
|
|
std::string d = n.domain.empty() ? "" : n.domain.front();
|
|
if (d.empty() || domain_set.find(d) == domain_set.end()) {
|
|
uint32_t h = fnv1a(n.id);
|
|
d = kDomainOrder[h % kDomainOrder.size()];
|
|
}
|
|
li.domain = d;
|
|
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 (refs to fn output) for ring/sector lookup later.
|
|
std::unordered_map<std::string, const fn_ring::LayoutOutput*> by_id;
|
|
by_id.reserve(out.size());
|
|
for (const auto& o : out) by_id.emplace(o.id, &o);
|
|
|
|
// ---- Anti-collision: redistribuir cada bin (ring,sector) como grid 2D ----
|
|
// compute_ring_layout solo distribuye RADIAL dentro del bin. Cuando hay
|
|
// muchos nodos por bin, se pisan. Aqui les damos tambien spread ANGULAR
|
|
// dentro del slice del sector.
|
|
std::unordered_map<std::string, std::pair<float,float>> final_pos;
|
|
final_pos.reserve(out.size());
|
|
for (const auto& o : out) final_pos[o.id] = { o.x, o.y };
|
|
|
|
{
|
|
std::map<std::pair<int,int>, std::vector<const fn_ring::LayoutOutput*>> bins;
|
|
for (const auto& o : out) {
|
|
if (o.ring < 0) continue;
|
|
bins[{o.ring, o.sector}].push_back(&o);
|
|
}
|
|
const float min_sep = kNodeRadius * 2.0f * 1.2f; // world units
|
|
const int n_sectors = 18;
|
|
const float sector_angle = 2.0f * 3.14159265f / float(n_sectors);
|
|
const float pad_r = 35.0f;
|
|
const float kStart = -1.5708f;
|
|
|
|
for (auto& [key, list] : bins) {
|
|
int ring = key.first;
|
|
int sector = key.second;
|
|
if (list.size() < 2) continue;
|
|
std::sort(list.begin(), list.end(),
|
|
[](const fn_ring::LayoutOutput* a, const fn_ring::LayoutOutput* b) {
|
|
return a->id < b->id;
|
|
});
|
|
|
|
float r_inner = kRingRadii[ring];
|
|
if (r_inner == 0.0f) r_inner = 30.0f;
|
|
float r_outer = kRingRadii[ring + 1];
|
|
float r_lo = r_inner + pad_r;
|
|
float r_hi = r_outer - pad_r;
|
|
if (r_lo > r_hi) { r_lo = r_hi = 0.5f * (r_inner + r_outer); }
|
|
float r_mid = 0.5f * (r_lo + r_hi);
|
|
|
|
float theta_center = kStart + (float(sector) + 0.5f) * sector_angle;
|
|
float half_arc = sector_angle * 0.45f;
|
|
float theta_lo = theta_center - half_arc;
|
|
float theta_hi = theta_center + half_arc;
|
|
|
|
int N = int(list.size());
|
|
int rad_cap = std::max(1, int((r_hi - r_lo) / min_sep));
|
|
int ang_cap = std::max(1, int(((theta_hi - theta_lo) * r_mid) / min_sep));
|
|
|
|
// Elige cols/rows para que cols*rows >= N, prefiriendo angular si
|
|
// el sector es ancho a ese radio.
|
|
int cols = std::clamp(int(std::ceil(std::sqrt(float(N) * float(ang_cap) / float(std::max(1, rad_cap))))), 1, ang_cap);
|
|
int rows = (N + cols - 1) / cols;
|
|
if (rows > rad_cap) {
|
|
rows = rad_cap;
|
|
cols = (N + rows - 1) / rows;
|
|
}
|
|
if (rows < 1) rows = 1;
|
|
if (cols < 1) cols = 1;
|
|
|
|
for (int k = 0; k < N; ++k) {
|
|
int row = k / cols;
|
|
int col = k % cols;
|
|
float r = (rows == 1) ? r_mid
|
|
: r_lo + (float(row) + 0.5f) * (r_hi - r_lo) / float(rows);
|
|
float col_offset = (row & 1) ? 0.5f : 0.0f; // brick offset entre filas
|
|
float t = (cols == 1) ? theta_center
|
|
: theta_lo + (float(col) + 0.5f + col_offset)
|
|
* (theta_hi - theta_lo) / float(cols + ((row & 1) ? 1 : 0));
|
|
final_pos[list[k]->id] = { std::cos(t) * r, std::sin(t) * r };
|
|
}
|
|
}
|
|
}
|
|
|
|
double now = now_seconds();
|
|
for (auto& n : nodes) {
|
|
auto it_r = by_id.find(n.id);
|
|
if (it_r == by_id.end()) continue;
|
|
const auto& o = *it_r->second;
|
|
auto it_p = final_pos.find(n.id);
|
|
float fx = (it_p != final_pos.end()) ? it_p->second.first : o.x;
|
|
float fy = (it_p != final_pos.end()) ? it_p->second.second : o.y;
|
|
|
|
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()) {
|
|
n.prev_x = fx;
|
|
n.prev_y = fy;
|
|
n.anim_start = now - kAnimDur;
|
|
}
|
|
n.x = fx;
|
|
n.y = fy;
|
|
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<std::string, Node> 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_sel_kind = SelKind::None;
|
|
g_sel_index = -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<std::string, int> 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;
|
|
g_hover_kind = SelKind::None;
|
|
const ImVec2 mp = ImGui::GetMousePos();
|
|
const float node_r_issue = kNodeRadius * g_cam_zoom;
|
|
const float node_r_flow = kNodeRadius * kFlowRadiusMul * g_cam_zoom;
|
|
const float node_r_draft = kNodeRadius * 1.25f * 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; g_hover_kind = SelKind::Node; }
|
|
|
|
ImU32 col = bucket_color(status_bucket(n.status_eff));
|
|
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_sel_kind == SelKind::Node && g_sel_index == i)) ? IM_COL32(255, 255, 255, 255)
|
|
: IM_COL32(34, 211, 238, 255); // cyan-400 always
|
|
dl->AddPolyline(pts, 4, outline, ImDrawFlags_Closed,
|
|
((g_sel_kind == SelKind::Node && g_sel_index == i)) ? 3.0f : 2.0f);
|
|
} else {
|
|
dl->AddCircleFilled(sp, r, col, 20);
|
|
ImU32 outline = ((g_sel_kind == SelKind::Node && g_sel_index == 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_sel_kind == SelKind::Node && g_sel_index == 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). Fix width up-front so the first frame
|
|
// doesnt flash a giant window before reflow.
|
|
if (over) {
|
|
const float kTipW = 360.0f;
|
|
ImGui::SetNextWindowSize(ImVec2(kTipW, 0.0f), ImGuiCond_Always);
|
|
ImGui::BeginTooltip();
|
|
ImGui::PushTextWrapPos(kTipW - 16.0f);
|
|
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::PopTextWrapPos();
|
|
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);
|
|
|
|
// Pass 3: drafts (ghost-nodes). Emerge desde source y se animan al target.
|
|
const double now_draft = now_seconds();
|
|
for (int i = 0; i < (int)g_drafts.size(); ++i) {
|
|
DraftNode& d = g_drafts[i];
|
|
float t = float((now_draft - d.spawn_t) / 1.4);
|
|
if (t < 0) continue;
|
|
if (t > 1.0f) { t = 1.0f; d.anim_done = true; }
|
|
float e = t * t * (3.0f - 2.0f * t); // ease-in-out
|
|
float wx = d.src_x + (d.tgt_x - d.src_x) * e;
|
|
float wy = d.src_y + (d.tgt_y - d.src_y) * e;
|
|
ImVec2 sp(origin_with_pan.x + wx * g_cam_zoom,
|
|
origin_with_pan.y + wy * g_cam_zoom);
|
|
|
|
if (sp.x < p0.x - node_r_draft || sp.x > p1.x + node_r_draft) continue;
|
|
if (sp.y < p0.y - node_r_draft || sp.y > p1.y + node_r_draft) continue;
|
|
|
|
float ddx = mp.x - sp.x, ddy = mp.y - sp.y;
|
|
bool over = hovered && (ddx * ddx + ddy * ddy) < node_r_draft * node_r_draft;
|
|
if (over) { g_hover = i; g_hover_kind = SelKind::Draft; }
|
|
|
|
// Color tipo: issue=azul, flow=cyan. Pulsing alpha.
|
|
float pulse = 0.55f + 0.35f * std::sin(float(now_draft) * 4.5f);
|
|
ImU32 fill = (d.proposed_type == "flow")
|
|
? IM_COL32( 14, 165, 233, int(pulse * 200)) // sky-500
|
|
: IM_COL32( 59, 130, 246, int(pulse * 200)); // blue-500
|
|
ImU32 ring = IM_COL32(255, 255, 255, int(pulse * 230));
|
|
|
|
// Outline pulse (mas grande que el fill) — dashes via segmented circle.
|
|
dl->AddCircle(sp, node_r_draft * 1.45f, ring, 32, 1.0f);
|
|
dl->AddCircleFilled(sp, node_r_draft, fill, 24);
|
|
bool is_sel = (g_sel_kind == SelKind::Draft && g_sel_index == i);
|
|
dl->AddCircle(sp, node_r_draft, is_sel ? IM_COL32(255, 255, 255, 255) : IM_COL32(255, 255, 255, 200),
|
|
24, is_sel ? 2.5f : 1.4f);
|
|
|
|
// Label: TMP + short title prefix.
|
|
if (g_cam_zoom > 0.55f) {
|
|
std::string lbl = std::string("? ") + d.title.substr(0, 18);
|
|
ImVec2 ts = ImGui::CalcTextSize(lbl.c_str());
|
|
ImVec2 tp(sp.x - ts.x * 0.5f, sp.y + node_r_draft + 2.0f);
|
|
dl->AddText(ImVec2(tp.x + 1, tp.y + 1), IM_COL32(0, 0, 0, 220), lbl.c_str());
|
|
dl->AddText(tp, IM_COL32(255, 255, 255, 245), lbl.c_str());
|
|
}
|
|
|
|
if (over) {
|
|
const float kTipW = 360.0f;
|
|
ImGui::SetNextWindowSize(ImVec2(kTipW, 0.0f), ImGuiCond_Always);
|
|
ImGui::BeginTooltip();
|
|
ImGui::PushTextWrapPos(kTipW - 16.0f);
|
|
ImGui::Text("[DRAFT] %s", d.proposed_type.c_str());
|
|
ImGui::TextWrapped("%s", d.title.c_str());
|
|
ImGui::TextDisabled("origen: %s · domain: %s", d.source_id.c_str(), d.proposed_domain.c_str());
|
|
ImGui::PopTextWrapPos();
|
|
ImGui::EndTooltip();
|
|
}
|
|
}
|
|
|
|
// Click: select.
|
|
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && g_hover >= 0) {
|
|
g_sel_kind = g_hover_kind;
|
|
g_sel_index = 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();
|
|
}
|
|
|
|
// ---- Draft helpers ------------------------------------------------------
|
|
|
|
static std::string slugify(const std::string& s) {
|
|
std::string out;
|
|
out.reserve(s.size());
|
|
for (char c : s) {
|
|
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) out.push_back(c);
|
|
else if (c >= 'A' && c <= 'Z') out.push_back(char(c - 'A' + 'a'));
|
|
else if (c == ' ' || c == '-' || c == '_' || c == '/') out.push_back('-');
|
|
}
|
|
// collapse multiple dashes
|
|
std::string c2;
|
|
bool prev_dash = false;
|
|
for (char c : out) {
|
|
if (c == '-') { if (!prev_dash) c2.push_back(c); prev_dash = true; }
|
|
else { c2.push_back(c); prev_dash = false; }
|
|
}
|
|
while (!c2.empty() && c2.front() == '-') c2.erase(0, 1);
|
|
while (!c2.empty() && c2.back() == '-') c2.pop_back();
|
|
if (c2.size() > 48) c2.resize(48);
|
|
return c2.empty() ? std::string("untitled") : c2;
|
|
}
|
|
|
|
// Devuelve el primer NNNN libre escaneando dev/issues/ y dev/issues/completed/.
|
|
static std::string next_issue_id(const fs::path& root) {
|
|
int max_n = 0;
|
|
auto scan = [&](const fs::path& dir) {
|
|
if (!fs::exists(dir)) return;
|
|
for (const auto& e : fs::directory_iterator(dir)) {
|
|
if (!e.is_regular_file()) continue;
|
|
auto stem = e.path().stem().string();
|
|
// Match 4-digit prefix followed by - or letter.
|
|
if (stem.size() >= 4) {
|
|
bool all_digit = true;
|
|
for (int i = 0; i < 4; ++i) if (stem[i] < '0' || stem[i] > '9') { all_digit = false; break; }
|
|
if (all_digit) {
|
|
int n = std::atoi(stem.substr(0, 4).c_str());
|
|
if (n > max_n) max_n = n;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
scan(root / "dev" / "issues");
|
|
scan(root / "dev" / "issues" / "completed");
|
|
char buf[16];
|
|
std::snprintf(buf, sizeof(buf), "%04d", max_n + 1);
|
|
return buf;
|
|
}
|
|
|
|
static std::string next_flow_id(const fs::path& root) {
|
|
int max_n = 0;
|
|
auto scan = [&](const fs::path& dir) {
|
|
if (!fs::exists(dir)) return;
|
|
for (const auto& e : fs::directory_iterator(dir)) {
|
|
if (!e.is_regular_file()) continue;
|
|
auto stem = e.path().stem().string();
|
|
if (stem.size() >= 4) {
|
|
bool all_digit = true;
|
|
for (int i = 0; i < 4; ++i) if (stem[i] < '0' || stem[i] > '9') { all_digit = false; break; }
|
|
if (all_digit) {
|
|
int n = std::atoi(stem.substr(0, 4).c_str());
|
|
if (n > max_n) max_n = n;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
scan(root / "dev" / "flows");
|
|
scan(root / "dev" / "flows" / "completed");
|
|
char buf[16];
|
|
std::snprintf(buf, sizeof(buf), "%04d", max_n + 1);
|
|
return buf;
|
|
}
|
|
|
|
static std::string today_iso() {
|
|
using namespace std::chrono;
|
|
auto tt = system_clock::to_time_t(system_clock::now());
|
|
std::tm tmv{};
|
|
#ifdef _WIN32
|
|
localtime_s(&tmv, &tt);
|
|
#else
|
|
localtime_r(&tt, &tmv);
|
|
#endif
|
|
char buf[16];
|
|
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
|
|
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday);
|
|
return buf;
|
|
}
|
|
|
|
// Encuentra la posicion objetivo para un draft segun su proposed_domain.
|
|
// Coloca el draft en la mitad del ring 2 (unlocked) sector de su domain.
|
|
static void compute_draft_target(DraftNode& d) {
|
|
std::unordered_set<std::string> domain_set(kDomainOrder.begin(), kDomainOrder.end());
|
|
std::string dom = d.proposed_domain;
|
|
if (dom.empty() || domain_set.find(dom) == domain_set.end()) {
|
|
dom = kDomainOrder[fnv1a(d.id) % kDomainOrder.size()];
|
|
}
|
|
int sector = (int)kDomainOrder.size() - 1;
|
|
for (int i = 0; i < (int)kDomainOrder.size(); ++i) {
|
|
if (kDomainOrder[i] == dom) { sector = i; break; }
|
|
}
|
|
const float r_lo = kRingRadii[2] + 30.0f;
|
|
const float r_hi = kRingRadii[3] - 30.0f;
|
|
const float r = 0.5f * (r_lo + r_hi);
|
|
const float theta = -1.5708f + (sector + 0.5f) * (2.0f * 3.14159265f / 18.0f);
|
|
d.tgt_x = std::cos(theta) * r;
|
|
d.tgt_y = std::sin(theta) * r;
|
|
}
|
|
|
|
// Mock LLM: genera 3-5 ideas plausibles para un nodo source.
|
|
static void mock_generate_ideas(const Node& source) {
|
|
static const char* kVerbsIssue[] = {
|
|
"Anadir", "Refactor", "Test golden", "Documentar", "Audit",
|
|
"Migrar", "Limpiar", "Validar", "Profiling",
|
|
};
|
|
static const char* kVerbsFlow[] = {
|
|
"Smoke flow", "Use-case end-to-end", "Demo escenario",
|
|
};
|
|
int N = 3 + (int)(fnv1a(source.id) % 3); // 3..5
|
|
double now = now_seconds();
|
|
for (int i = 0; i < N; ++i) {
|
|
DraftNode d;
|
|
d.id = "tmp_" + std::to_string(g_next_draft_n++);
|
|
d.source_id = source.id;
|
|
bool as_flow = (i == N - 1); // last one as flow
|
|
d.proposed_type = as_flow ? "flow" : "issue";
|
|
const char* verb = as_flow
|
|
? kVerbsFlow[fnv1a(d.id) % (sizeof(kVerbsFlow)/sizeof(kVerbsFlow[0]))]
|
|
: kVerbsIssue[fnv1a(d.id) % (sizeof(kVerbsIssue)/sizeof(kVerbsIssue[0]))];
|
|
d.title = std::string(verb) + " " + source.title.substr(0, 40);
|
|
d.description = "Idea generada (mock) a partir de " + source.id +
|
|
". Sustituye este texto con LLM real (claude -p) en 0109h2.";
|
|
d.proposed_domain = source.domain.empty() ? "meta" : source.domain.front();
|
|
d.proposed_priority = "media";
|
|
d.proposed_dod = { "DoD item 1", "DoD item 2", "DoD item 3" };
|
|
d.src_x = source.x;
|
|
d.src_y = source.y;
|
|
compute_draft_target(d);
|
|
d.spawn_t = now + i * 0.15f; // stagger
|
|
g_drafts.push_back(std::move(d));
|
|
}
|
|
fn_log::log_info("skill_tree: generated %d mock drafts from %s", N, source.id.c_str());
|
|
}
|
|
|
|
// Promote draft → archivo .md en dev/issues/ o dev/flows/.
|
|
static bool promote_draft(int draft_idx, bool as_flow) {
|
|
if (draft_idx < 0 || draft_idx >= (int)g_drafts.size()) return false;
|
|
const auto& d = g_drafts[draft_idx];
|
|
std::string id = as_flow ? next_flow_id(g_root) : next_issue_id(g_root);
|
|
std::string slug = slugify(d.title);
|
|
fs::path out_dir = g_root / "dev" / (as_flow ? "flows" : "issues");
|
|
fs::create_directories(out_dir);
|
|
fs::path out_file = out_dir / (id + "-" + slug + ".md");
|
|
|
|
std::ostringstream ss;
|
|
ss << "---\n";
|
|
if (as_flow) {
|
|
ss << "name: " << slug << "\n";
|
|
ss << "id: " << id << "\n";
|
|
ss << "status: pending\n";
|
|
} else {
|
|
ss << "id: \"" << id << "\"\n";
|
|
ss << "title: \"" << d.title << "\"\n";
|
|
ss << "status: pendiente\n";
|
|
ss << "type: feature\n";
|
|
}
|
|
ss << "domain:\n - " << d.proposed_domain << "\n";
|
|
ss << "priority: " << d.proposed_priority << "\n";
|
|
if (as_flow) {
|
|
ss << "related_issues: []\n";
|
|
ss << "apps: []\n";
|
|
ss << "trigger: manual\n";
|
|
} else {
|
|
ss << "depends: []\n";
|
|
ss << "blocks: []\n";
|
|
ss << "related:\n - \"" << d.source_id << "\"\n";
|
|
}
|
|
ss << "created: " << today_iso() << "\n";
|
|
ss << "updated: " << today_iso() << "\n";
|
|
ss << "tags: [skill-tree-draft]\n";
|
|
ss << "---\n\n";
|
|
ss << "# " << id << " — " << d.title << "\n\n";
|
|
ss << "Origen: idea generada desde nodo " << d.source_id << " via skill_tree.\n\n";
|
|
ss << d.description << "\n\n";
|
|
if (!d.proposed_dod.empty()) {
|
|
ss << "## DoD\n\n";
|
|
for (const auto& item : d.proposed_dod) ss << "- [ ] " << item << "\n";
|
|
ss << "\n";
|
|
}
|
|
|
|
std::ofstream f(out_file);
|
|
if (!f) { fn_log::log_warn("skill_tree: failed to open %s for write", out_file.string().c_str()); return false; }
|
|
f << ss.str();
|
|
f.close();
|
|
fn_log::log_info("skill_tree: promoted draft %s -> %s", d.id.c_str(), out_file.string().c_str());
|
|
|
|
// Remove draft from buffer.
|
|
g_drafts.erase(g_drafts.begin() + draft_idx);
|
|
if (g_sel_kind == SelKind::Draft && g_sel_index == draft_idx) {
|
|
g_sel_kind = SelKind::None;
|
|
g_sel_index = -1;
|
|
} else if (g_sel_kind == SelKind::Draft && g_sel_index > draft_idx) {
|
|
--g_sel_index;
|
|
}
|
|
// Trigger reload to pick up the new file.
|
|
reload_scan();
|
|
return true;
|
|
}
|
|
|
|
static void draw_inspector_draft();
|
|
static void draw_dashboard();
|
|
|
|
// ---- Panels -------------------------------------------------------------
|
|
|
|
static void draw_tree() {
|
|
if (!ImGui::Begin(TI_GRAPH " Tree", &g_show_tree)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Banner si no encontramos registry root.
|
|
if (g_root.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(80, 20, 20, 255));
|
|
ImGui::BeginChild("root_err", ImVec2(0, 80), ImGuiChildFlags_Borders);
|
|
ImGui::TextColored(ImVec4(1, 0.6f, 0.6f, 1),
|
|
TI_ALERT_TRIANGLE " No se encontro registry root.");
|
|
ImGui::TextWrapped("Probadas: env FN_REGISTRY_ROOT, walk-up desde cwd"
|
|
#ifdef _WIN32
|
|
", env WSL_REGISTRY_PATH, \\\\wsl.localhost\\Ubuntu\\home\\lucas\\fn_registry, \\\\wsl$\\..."
|
|
#endif
|
|
". Setea FN_REGISTRY_ROOT (o WSL_REGISTRY_PATH en Win) y relanza."
|
|
);
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
// 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_sel_kind == SelKind::Draft) {
|
|
draw_inspector_draft();
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
if (g_sel_kind != SelKind::Node ||
|
|
g_sel_index < 0 || g_sel_index >= (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_sel_index];
|
|
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();
|
|
|
|
// === Launch workflow / Claude fix ===
|
|
//
|
|
// Default path: POST to http://localhost:8486/api/runs and let the
|
|
// agent_runner_api spawn the worker headless. Legacy path (terminal
|
|
// external + interactive claude CLI) sits behind feature flag
|
|
// `legacy_claude_fix` in dev/feature_flags.json. Default OFF (issue 0116).
|
|
static double s_last_launch_t = -1e9;
|
|
static bool s_last_launch_ok = true;
|
|
static std::string s_last_launch_id;
|
|
|
|
const Bucket b = status_bucket(n.status_eff);
|
|
const bool fixable = (b != Bucket::Done);
|
|
if (fixable) {
|
|
// Launch workflow (always available): async POST to agent_runner_api.
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 16, 185, 129, 220));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 52, 211, 153, 250));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 6, 148, 110, 255));
|
|
if (ImGui::Button(TI_ROCKET " Launch workflow")) {
|
|
launch_workflow_async(n.id);
|
|
fn_log::log_info("skill_tree: launch_workflow requested for %s", n.id.c_str());
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("POST :8486/api/runs (fix-issue) — agent corre headless");
|
|
|
|
// Legacy Claude fix: only rendered when feature flag is ON.
|
|
if (g_legacy_claude_fix_flag) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(168, 85, 247, 200));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(192, 132, 252, 240));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(147, 51, 234, 255));
|
|
if (ImGui::Button(TI_TERMINAL_2 " Claude fix")) {
|
|
s_last_launch_ok = spawn_claude_terminal(g_root);
|
|
s_last_launch_t = now_seconds();
|
|
s_last_launch_id = n.id;
|
|
fn_log::log_info("skill_tree: spawn claude terminal for %s -> %s",
|
|
n.id.c_str(), s_last_launch_ok ? "ok" : "fail");
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("legacy: abre terminal externa con `claude --dangerously-skip-permissions`");
|
|
}
|
|
} else {
|
|
ImGui::TextDisabled("Issue done — no fix necesario.");
|
|
}
|
|
|
|
// Toast del Launch workflow (run_id / error). Thread-safe lectura.
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_launch_toast.mu);
|
|
if (!g_launch_toast.text.empty() && now_seconds() < g_launch_toast.expires_at) {
|
|
if (g_launch_toast.ok) {
|
|
ImGui::TextColored(ImVec4(0.4f, 0.95f, 0.5f, 1.0f),
|
|
TI_CHECK " %s", g_launch_toast.text.c_str());
|
|
} else {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f),
|
|
TI_ALERT_TRIANGLE " %s", g_launch_toast.text.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toast legacy Claude fix.
|
|
if (g_legacy_claude_fix_flag &&
|
|
s_last_launch_id == n.id && (now_seconds() - s_last_launch_t) < 5.0) {
|
|
if (s_last_launch_ok) {
|
|
ImGui::TextColored(ImVec4(0.4f, 0.95f, 0.5f, 1.0f),
|
|
TI_CHECK " terminal lanzada (revisa Windows Terminal / WSL)");
|
|
} else {
|
|
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f),
|
|
TI_ALERT_TRIANGLE " no se pudo lanzar terminal");
|
|
}
|
|
}
|
|
|
|
ImGui::Separator();
|
|
|
|
// === Generate ideas (mock LLM until 0109h2) ===
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 16, 185, 129, 200));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 52, 211, 153, 240));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 6, 148, 110, 255));
|
|
if (ImGui::Button(TI_PLUS " Generate ideas")) {
|
|
mock_generate_ideas(n);
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("emergen ghost-nodes con ideas. Click ghost para promover. (LLM real en 0109h2 — hoy mock)");
|
|
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Run autonomous-task llega en 0109f.");
|
|
ImGui::End();
|
|
}
|
|
|
|
// ---- Inspector: draft branch -------------------------------------------
|
|
|
|
static void draw_inspector_draft() {
|
|
if (g_sel_index < 0 || g_sel_index >= (int)g_drafts.size()) {
|
|
g_sel_kind = SelKind::None; g_sel_index = -1; return;
|
|
}
|
|
const DraftNode& d = g_drafts[g_sel_index];
|
|
ImGui::Text(TI_BULB " DRAFT (idea) · origen: %s", d.source_id.c_str());
|
|
ImGui::TextWrapped("%s", d.title.c_str());
|
|
ImGui::Separator();
|
|
ImGui::Text("tipo propuesto: %s", d.proposed_type.c_str());
|
|
ImGui::Text("domain propuesto:%s", d.proposed_domain.c_str());
|
|
ImGui::Text("priority: %s", d.proposed_priority.c_str());
|
|
|
|
ImGui::SeparatorText("descripcion");
|
|
ImGui::TextWrapped("%s", d.description.c_str());
|
|
|
|
if (!d.proposed_dod.empty()) {
|
|
ImGui::SeparatorText("DoD propuesto");
|
|
for (const auto& it : d.proposed_dod) ImGui::BulletText("%s", it.c_str());
|
|
}
|
|
|
|
ImGui::Separator();
|
|
|
|
// Promote buttons.
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 59, 130, 246, 200));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 96, 165, 250, 240));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 37, 99, 235, 255));
|
|
if (ImGui::Button(TI_GIT_BRANCH " Generate issue")) {
|
|
promote_draft(g_sel_index, /*as_flow=*/false);
|
|
ImGui::PopStyleColor(3);
|
|
return;
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
|
|
ImGui::SameLine();
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 14, 165, 233, 200));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 56, 189, 248, 240));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 2, 132, 199, 255));
|
|
if (ImGui::Button(TI_PLAYLIST_ADD " Generate flow")) {
|
|
promote_draft(g_sel_index, /*as_flow=*/true);
|
|
ImGui::PopStyleColor(3);
|
|
return;
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
|
|
ImGui::SameLine();
|
|
|
|
ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(115, 115, 115, 200));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(150, 150, 150, 240));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 90, 90, 90, 255));
|
|
if (ImGui::Button(TI_X " Discard")) {
|
|
g_drafts.erase(g_drafts.begin() + g_sel_index);
|
|
g_sel_kind = SelKind::None; g_sel_index = -1;
|
|
ImGui::PopStyleColor(3);
|
|
return;
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
}
|
|
|
|
// ---- Dashboard ----------------------------------------------------------
|
|
|
|
static int xp_for_type(const std::string& type, NodeKind kind) {
|
|
if (kind == NodeKind::Flow) return 5;
|
|
if (type == "epic") return 10;
|
|
if (type == "feature") return 3;
|
|
if (type == "infra") return 4;
|
|
if (type == "refactor") return 2;
|
|
if (type == "bugfix") return 1;
|
|
if (type == "chore") return 1;
|
|
if (type == "docs") return 1;
|
|
if (type == "spike") return 2;
|
|
if (type == "planning") return 2;
|
|
return 1;
|
|
}
|
|
|
|
static void draw_dashboard() {
|
|
if (!ImGui::Begin(TI_CHART_BAR " Dashboard", &g_show_dashboard)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Per-domain accumulators.
|
|
struct DomStat {
|
|
int done = 0, planned = 0, todo = 0, total = 0;
|
|
int xp = 0;
|
|
};
|
|
std::unordered_map<std::string, DomStat> per_dom;
|
|
int xp_total = 0;
|
|
int xp_by_type_done[16] = {0}; // index by enum-ish ordering, but we keep simple
|
|
int n_done = 0, n_planned = 0, n_todo = 0;
|
|
|
|
for (const auto& n : g_scan.nodes) {
|
|
int xp = xp_for_type(n.type, n.kind);
|
|
Bucket b = status_bucket(n.status_eff);
|
|
if (b == Bucket::Done) ++n_done;
|
|
else if (b == Bucket::Planned) ++n_planned;
|
|
else ++n_todo;
|
|
|
|
if (b == Bucket::Done) xp_total += xp;
|
|
|
|
// Per domain (cuenta una vez en cada dominio listado).
|
|
std::vector<std::string> doms = n.domain;
|
|
if (doms.empty()) doms.push_back("(unknown)");
|
|
for (const auto& d : doms) {
|
|
auto& s = per_dom[d];
|
|
++s.total;
|
|
if (b == Bucket::Done) { ++s.done; s.xp += xp; }
|
|
else if (b == Bucket::Planned) { ++s.planned; }
|
|
else { ++s.todo; }
|
|
}
|
|
}
|
|
|
|
int level = (int)std::floor(std::sqrt((float)xp_total));
|
|
int xp_next_level = (level + 1) * (level + 1);
|
|
|
|
// HUD top.
|
|
ImGui::Text(TI_TROPHY " LV %d", level);
|
|
ImGui::SameLine();
|
|
ImGui::Text("· XP %d · next LV at %d", xp_total, xp_next_level);
|
|
|
|
float xp_frac = (xp_next_level > 0) ? float(xp_total - level*level) / float(xp_next_level - level*level) : 0.0f;
|
|
ImGui::ProgressBar(xp_frac, ImVec2(-FLT_MIN, 0.0f),
|
|
(std::to_string(xp_total - level*level) + " / " +
|
|
std::to_string(xp_next_level - level*level) + " XP").c_str());
|
|
|
|
ImGui::Separator();
|
|
ImGui::Text("done: %d · planned: %d · todo: %d · drafts: %d",
|
|
n_done, n_planned, n_todo, (int)g_drafts.size());
|
|
|
|
ImGui::SeparatorText("Habilidades por dominio");
|
|
|
|
if (ImGui::BeginTable("doms", 6, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_Sortable)) {
|
|
ImGui::TableSetupColumn("Dominio");
|
|
ImGui::TableSetupColumn("Done", ImGuiTableColumnFlags_WidthFixed, 50.0f);
|
|
ImGui::TableSetupColumn("Planned", ImGuiTableColumnFlags_WidthFixed, 60.0f);
|
|
ImGui::TableSetupColumn("Todo", ImGuiTableColumnFlags_WidthFixed, 50.0f);
|
|
ImGui::TableSetupColumn("Progreso", ImGuiTableColumnFlags_WidthStretch);
|
|
ImGui::TableSetupColumn("LV", ImGuiTableColumnFlags_WidthFixed, 50.0f);
|
|
ImGui::TableHeadersRow();
|
|
|
|
// Ordenar por % completado desc.
|
|
std::vector<std::pair<std::string, DomStat>> ord(per_dom.begin(), per_dom.end());
|
|
std::sort(ord.begin(), ord.end(), [](auto& a, auto& b) {
|
|
float pa = a.second.total > 0 ? float(a.second.done) / a.second.total : 0;
|
|
float pb = b.second.total > 0 ? float(b.second.done) / b.second.total : 0;
|
|
if (pa != pb) return pa > pb;
|
|
return a.second.done > b.second.done;
|
|
});
|
|
for (const auto& [name, s] : ord) {
|
|
ImGui::TableNextRow();
|
|
ImGui::TableNextColumn(); ImGui::TextUnformatted(name.c_str());
|
|
ImGui::TableNextColumn(); ImGui::Text("%d", s.done);
|
|
ImGui::TableNextColumn(); ImGui::Text("%d", s.planned);
|
|
ImGui::TableNextColumn(); ImGui::Text("%d", s.todo);
|
|
float frac = s.total > 0 ? float(s.done) / float(s.total) : 0.0f;
|
|
ImGui::TableNextColumn();
|
|
char ovl[24]; std::snprintf(ovl, sizeof(ovl), "%d/%d (%d%%)", s.done, s.total, int(frac*100));
|
|
ImGui::ProgressBar(frac, ImVec2(-FLT_MIN, 0), ovl);
|
|
int lv = (int)std::floor(std::sqrt((float)s.xp));
|
|
ImGui::TableNextColumn(); ImGui::Text("LV %d", lv);
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
|
|
ImGui::SeparatorText("Top 3 dominios mas masterizados");
|
|
{
|
|
std::vector<std::pair<std::string, DomStat>> top(per_dom.begin(), per_dom.end());
|
|
std::sort(top.begin(), top.end(), [](auto& a, auto& b) {
|
|
float pa = a.second.total > 0 ? float(a.second.done) / a.second.total : 0;
|
|
float pb = b.second.total > 0 ? float(b.second.done) / b.second.total : 0;
|
|
return pa > pb;
|
|
});
|
|
int shown = 0;
|
|
for (const auto& [name, s] : top) {
|
|
if (s.total < 2) continue; // skip noise
|
|
if (shown >= 3) break;
|
|
float p = float(s.done) / float(s.total);
|
|
ImGui::BulletText("%s — %d/%d (%.0f%%)", name.c_str(), s.done, s.total, p*100);
|
|
++shown;
|
|
}
|
|
}
|
|
|
|
ImGui::SeparatorText("Proximos a desbloquearse (mas locked)");
|
|
{
|
|
std::vector<std::pair<std::string, DomStat>> top(per_dom.begin(), per_dom.end());
|
|
std::sort(top.begin(), top.end(), [](auto& a, auto& b) {
|
|
return a.second.todo > b.second.todo;
|
|
});
|
|
int shown = 0;
|
|
for (const auto& [name, s] : top) {
|
|
if (s.todo < 1) break;
|
|
if (shown >= 3) break;
|
|
ImGui::BulletText("%s — %d todo (%d done)", name.c_str(), s.todo, s.done);
|
|
++shown;
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
static void render() {
|
|
// Defer first reload to first frame so fn_log is initialized.
|
|
static bool first_init = true;
|
|
if (first_init) {
|
|
first_init = false;
|
|
g_root = discover_registry_root();
|
|
fn_log::log_info("skill_tree: discover_registry_root -> '%s'",
|
|
g_root.empty() ? "(empty)" : g_root.string().c_str());
|
|
load_feature_flags(g_root);
|
|
fn_log::log_info("skill_tree: legacy_claude_fix flag = %s",
|
|
g_legacy_claude_fix_flag ? "ON" : "OFF");
|
|
reload_scan();
|
|
}
|
|
if (g_show_tree) draw_tree();
|
|
if (g_show_inspector) draw_inspector();
|
|
if (g_show_dashboard) draw_dashboard();
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
|
|
// discover_registry_root + reload_scan move to first render() frame
|
|
// (fn_log not initialized here yet — would lose discovery diagnostics).
|
|
|
|
static fn_ui::PanelToggle panels[] = {
|
|
{ "Tree", nullptr, &g_show_tree },
|
|
{ "Inspector", nullptr, &g_show_inspector },
|
|
{ "Dashboard", nullptr, &g_show_dashboard },
|
|
};
|
|
|
|
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);
|
|
}
|