Project file structure:
=======================
./
    __init__.py
    __main__.py
    config.py
    nova_core/
        __init__.py
        auth/
            __init__.py
            client.py
            storage.py
        ai/
            __init__.py
            api_client.py
            utils.py
        performance/
    cli/
        __init__.py
        commands.py
        main.py
        registry.py
        shell.py
        shell_parts/
            ai_logic.py
            handlers.py
            ui_utils.py
    local/
        file_manager.py
        ui.py
        utils.py
        eda_pipeline/
        healer/
            __init__.py
            runner.py
        data_engine/
        file_manager/
            __init__.py
            commands.py
            edit_ops.py
            git_ops.py
            io_ops.py
            path_ops.py
        contextifier/
            __init__.py
            engine.py


File Contents:
===============


--- FILE: __init__.py ---

import importlib.metadata

try:
    # Dynamically fetch the version installed via pip
    __version__ = importlib.metadata.version("nova-bridgeye")
except importlib.metadata.PackageNotFoundError:
    # Fallback if running from source without installing
    __version__ = "0.1.5.2"

--- FILE: __main__.py ---

from nova_cli.cli.main import main

if __name__ == "__main__":
    main()


--- FILE: config.py ---

import os
from dotenv import load_dotenv

load_dotenv()


# -----------------------------
# NOVA CLI (API-only) Config
# -----------------------------
# This repo MUST NOT store model provider API keys.
# All AI + execution happens via NOVA_API over HTTP.
# Auth happens via nova-web.
# -----------------------------

# Location / display (optional; used only for UX)
LOCATION = os.getenv("NOVA_LOCATION", "India")
USER_NAME = os.getenv("NOVA_USER_NAME", "User")

# Defaults for CLI requests (sent to API)
DEFAULT_PROVIDER = os.getenv("NOVA_DEFAULT_PROVIDER", "openrouter")
DEFAULT_MODEL = os.getenv("NOVA_DEFAULT_MODEL", "openai/gpt-oss-120b")

# Base URLs
# - NOVA_API_BASE_URL points to the NOVA_API server (local or remote)
# - NOVA_AUTH_BASE_URL points to nova-web (Render)
NOVA_API_BASE_URL = os.getenv("NOVA_API_BASE_URL", "https://api.nova.bridgeye.com")
NOVA_AUTH_BASE_URL = os.getenv("NOVA_AUTH_BASE_URL", "https://nova.bridgeye.com")

# Client identity
NOVA_USER_AGENT = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")

# Debug toggles
DEBUG_AUTH = os.getenv("NOVA_DEBUG_AUTH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Git behavior (CLI-only UX; file_manager uses these)
GIT_AUTO_COMMIT = os.getenv("NOVA_GIT_AUTO_COMMIT", "").strip() in ("1", "true", "TRUE", "yes", "YES")
GIT_AUTO_PUSH = os.getenv("NOVA_GIT_AUTO_PUSH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Context load toggle (CLI-side feature; does not affect API)
AUTO_CONTEXT_LOAD = os.getenv("NOVA_AUTO_CONTEXT_LOAD", "").strip() in ("1", "true", "TRUE", "yes", "YES")

# Overdrive Mode (Bypass [y/n] confirmations)
OVERDRIVE = False

# Dynamic Root Boundary
INITIAL_ROOT = os.path.abspath(os.getcwd())
PROJECT_ROOT = INITIAL_ROOT


--- FILE: nova_core/__init__.py ---



--- FILE: nova_core/auth/__init__.py ---



--- FILE: nova_core/auth/client.py ---

# NOVA_CLI\nova_cli\nova_core\auth\client.py

import os
import time
import webbrowser
import requests
from typing import Optional, Dict, Any


class NovaAuthClient:
    def __init__(self, base_url: Optional[str] = None):
        # Priority:
        # 1) passed base_url
        # 2) env NOVA_AUTH_BASE_URL
        # 3) default production
        resolved = base_url or os.getenv("NOVA_AUTH_BASE_URL") or "https://nova.bridgeye.com"
        self.base_url = resolved.rstrip("/")

        # Basic client identity (helps audit logs + debugging)
        self.user_agent = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")

    def _headers(self) -> Dict[str, str]:
        return {"User-Agent": self.user_agent}

    def create_session(self) -> str:
        # Render can cold-start, keep this lenient
        r = requests.post(
            f"{self.base_url}/auth/session",
            timeout=30,
            headers=self._headers(),
        )
        r.raise_for_status()
        data = r.json()
        return data["session_id"]

    def open_browser(self, session_id: str) -> None:
        url = f"{self.base_url}/login?session_id={session_id}"
        webbrowser.open(url)

    def poll_session(self, session_id: str, timeout: int = 300) -> Optional[str]:
        start = time.time()

        while time.time() - start < timeout:
            try:
                r = requests.get(
                    f"{self.base_url}/auth/session/{session_id}",
                    timeout=10,
                    headers=self._headers(),
                )

                if r.status_code == 200:
                    data = r.json()
                    if data.get("status") == "approved":
                        return data.get("auth_code")

            except Exception:
                pass

            time.sleep(2)

        return None

    def exchange_auth_code(self, auth_code: str) -> Dict[str, Any]:
        try:
            r = requests.post(
                f"{self.base_url}/auth/token",
                json={"auth_code": auth_code},
                timeout=20,
                headers=self._headers(),
            )

            if r.status_code != 200:
                return {
                    "error": "token_exchange_failed",
                    "status": r.status_code,
                    "body": r.text,
                }

            return r.json()

        except Exception as e:
            return {"error": "request_failed", "body": str(e)}

    def refresh_access(self, refresh_token: str) -> Dict[str, Any]:
        try:
            r = requests.post(
                f"{self.base_url}/auth/refresh",
                json={"refresh_token": refresh_token},
                timeout=20,
                headers=self._headers(),
            )

            if r.status_code != 200:
                return {
                    "error": "refresh_failed",
                    "status": r.status_code,
                    "body": r.text,
                }

            return r.json()

        except Exception as e:
            return {"error": "request_failed", "body": str(e)}


--- FILE: nova_core/auth/storage.py ---

# NOVA_CLI\nova_cli\nova_core\auth\storage.py

import os
import json
import time
from typing import Optional, Dict, Any

AUTH_DIR = os.path.expanduser("~/.nova")
AUTH_FILE = os.path.join(AUTH_DIR, "auth.json")

DEFAULT_EXPIRES_IN = 3600


def _now() -> float:
    return time.time()


def _read_json(path: str) -> Optional[Dict[str, Any]]:
    if not os.path.exists(path):
        return None
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def save_auth(data: dict) -> None:
    os.makedirs(AUTH_DIR, exist_ok=True)

    # Normalize issued_at
    if "issued_at" not in data or data["issued_at"] in (None, ""):
        data["issued_at"] = _now()
    else:
        try:
            data["issued_at"] = float(data["issued_at"])
        except Exception:
            data["issued_at"] = _now()

    # Normalize expires_in
    if "expires_in" in data and data["expires_in"] not in (None, ""):
        try:
            data["expires_in"] = int(data["expires_in"])
        except Exception:
            data["expires_in"] = DEFAULT_EXPIRES_IN
    else:
        # if missing, keep whatever is there; access expiry will be treated conservatively
        data.setdefault("expires_in", DEFAULT_EXPIRES_IN)

    with open(AUTH_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def load_auth() -> Optional[Dict[str, Any]]:
    return _read_json(AUTH_FILE)


def logout() -> None:
    if os.path.exists(AUTH_FILE):
        os.remove(AUTH_FILE)


def has_refresh_token() -> bool:
    data = load_auth()
    return bool(data and data.get("refresh_token"))


def _access_expired(data: Dict[str, Any]) -> bool:
    access = data.get("access_token")
    if not access:
        return True

    expires_in = data.get("expires_in")
    issued_at = data.get("issued_at")

    # If metadata missing, treat as expired so client will refresh safely.
    if expires_in in (None, "", 0) or issued_at in (None, ""):
        return True

    try:
        exp_ts = float(issued_at) + float(expires_in)
    except Exception:
        return True

    return _now() >= exp_ts


def is_logged_in() -> bool:
    """
    Strict check: True only if valid token strings exist.
    """
    data = load_auth()
    if not data or not isinstance(data, dict):
        return False

    has_access = bool(data.get("access_token"))
    has_refresh = bool(data.get("refresh_token"))

    if has_refresh:
        return True
    
    return has_access and not _access_expired(data)


def get_access_token() -> Optional[str]:
    data = load_auth()
    if not data:
        return None

    if _access_expired(data):
        return None

    return data.get("access_token")


def get_refresh_token() -> Optional[str]:
    data = load_auth()
    if not data:
        return None
    return data.get("refresh_token")


def update_tokens(
    access_token: str,
    refresh_token: str,
    expires_in: int = DEFAULT_EXPIRES_IN,
    issued_at: Optional[float] = None,
) -> None:
    data = load_auth() or {}
    data["access_token"] = access_token
    data["refresh_token"] = refresh_token
    data["expires_in"] = int(expires_in) if expires_in else DEFAULT_EXPIRES_IN
    data["issued_at"] = float(issued_at) if issued_at else _now()
    save_auth(data)


def update_access_token(
    access_token: str,
    expires_in: int = DEFAULT_EXPIRES_IN,
    issued_at: Optional[float] = None,
) -> None:
    data = load_auth() or {}
    data["access_token"] = access_token
    data["expires_in"] = int(expires_in) if expires_in else DEFAULT_EXPIRES_IN
    data["issued_at"] = float(issued_at) if issued_at else _now()
    save_auth(data)


--- FILE: nova_core/ai/__init__.py ---



--- FILE: nova_core/ai/api_client.py ---

# NOVA-CLI\nova_cli\nova_core\ai\api_client.py

import os
import requests
from typing import List, Optional, Dict, Any, Iterator
import json

from nova_cli.nova_core.auth.storage import (
    get_access_token,
    get_refresh_token,
    update_tokens,
)
from nova_cli.nova_core.auth.client import NovaAuthClient


class BridgeyeAPIClient:
    """
    Thin HTTP client used by Nova CLI.
    This client contains NO AI logic.
    """

    def __init__(self, base_url: Optional[str] = None, timeout: int = 1200):
        env_url = os.getenv("NOVA_API_BASE_URL")
        self.base_url = (base_url or env_url or "https://api.nova.bridgeye.com").rstrip("/")
        self.timeout = timeout
        self._last_refresh_error: Optional[Dict[str, Any]] = None

        self.user_agent = os.getenv("NOVA_USER_AGENT", "NovaCLI/1.0")
        # Set NOVA_DEBUG_AUTH=1 if you want verbose auth errors locally
        self.debug_auth = os.getenv("NOVA_DEBUG_AUTH", "").strip() in ("1", "true", "TRUE", "yes", "YES")

    def _headers(self) -> Dict[str, str]:
        headers: Dict[str, str] = {"User-Agent": self.user_agent}
        tok = get_access_token()
        if tok:
            headers["Authorization"] = f"Bearer {tok}"
        return headers

    def _debug(self, msg: str) -> None:
        if not self.debug_auth:
            return
        try:
            print(msg)
        except Exception:
            pass

    def _try_refresh(self) -> bool:
        rt = get_refresh_token()
        if not rt:
            self._last_refresh_error = {"kind": "no_refresh_token_on_disk"}
            return False

        auth = NovaAuthClient()
        resp = auth.refresh_access(rt)

        if not isinstance(resp, dict):
            self._last_refresh_error = {"kind": "refresh_bad_response_type", "type": str(type(resp))}
            self._debug(f"[auth] refresh failed: {self._last_refresh_error}")
            return False

        if resp.get("error"):
            self._last_refresh_error = {
                "kind": resp.get("error"),
                "status": resp.get("status"),
                "body": resp.get("body"),
            }
            self._debug(f"[auth] refresh failed: {self._last_refresh_error}")
            return False

        new_access = resp.get("access_token")
        new_refresh = resp.get("refresh_token")
        expires_in = resp.get("expires_in")
        issued_at = resp.get("issued_at")

        if not new_access or not new_refresh or not expires_in:
            self._last_refresh_error = {
                "kind": "refresh_missing_fields",
                "has_access": bool(new_access),
                "has_refresh": bool(new_refresh),
                "expires_in": expires_in,
            }
            self._debug(f"[auth] refresh failed: {self._last_refresh_error} resp={resp}")
            return False

        update_tokens(
            access_token=new_access,
            refresh_token=new_refresh,
            expires_in=int(expires_in),
            issued_at=float(issued_at) if issued_at else None,
        )

        self._last_refresh_error = None
        return True

    def _require_auth(self) -> str:
        tok = get_access_token()
        if tok:
            return tok

        if self._try_refresh():
            tok = get_access_token()
            if tok:
                return tok

        # Prettified Auth Error (Consistent with _parse_json)
        raise RuntimeError("Your session has expired or is invalid. Please run [bold]nova login[/bold] to continue.")

    def _post_with_auth_retry(self, path: str, payload: Dict[str, Any]) -> requests.Response:
        self._require_auth()
        url = f"{self.base_url}{path}"

        try:
            resp = requests.post(
                url,
                json=payload,
                headers=self._headers(),
                timeout=self.timeout,
            )
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError("Please check your internet connection. NOVA is unable to reach the server.")
        except requests.RequestException:
            raise RuntimeError("Network instability detected. Please check your internet and try again.")

        if resp.status_code != 401:
            return resp

        if self._try_refresh():
            try:
                resp = requests.post(
                    url,
                    json=payload,
                    headers=self._headers(),
                    timeout=self.timeout,
                )
            except requests.RequestException as e:
                raise RuntimeError(f"API request failed after refresh: {e}")

        return resp

    def _get_with_auth_retry(self, path: str) -> requests.Response:
        self._require_auth()
        url = f"{self.base_url}{path}"

        try:
            resp = requests.get(
                url,
                headers=self._headers(),
                timeout=self.timeout,
            )
        except requests.RequestException as e:
            raise RuntimeError(f"API request failed: {e}")

        if resp.status_code != 401:
            return resp

        if self._try_refresh():
            try:
                resp = requests.get(
                    url,
                    headers=self._headers(),
                    timeout=self.timeout,
                )
            except requests.RequestException as e:
                raise RuntimeError(f"API request failed after refresh: {e}")

        return resp

    def _parse_json(self, resp: requests.Response) -> Dict[str, Any]:
        if resp.status_code == 402:
            raise RuntimeError("Usage limit reached. Please check your account credits.")
        
        if resp.status_code == 401:
            raise RuntimeError("Your session has expired. Please run [bold]login[/bold] to continue.")

        if resp.status_code in (413, 429):
            raise RuntimeError("The selected model is currently at capacity. Please try again or switch models using [bold cyan]:model[/bold cyan].")

        if resp.status_code in (500, 502, 503, 504):
            raise RuntimeError("Connection error. The server is unreachable or timed out. Please try again in a few seconds.")

        try:
            data = resp.json()
            if isinstance(data, dict) and not data.get("success", True):
                err_msg = data.get("error", "").lower()
                if any(k in err_msg for k in ["rate_limit", "tpm", "tokens"]):
                    raise RuntimeError("Model capacity reached. Please switch models using [bold cyan]:model[/bold cyan].")
                # If error exists in JSON but isn't recognized, return a clean generic message
                raise RuntimeError("An internal process error occurred. Please try again.")
            return data
        except (ValueError, KeyError):
            raise RuntimeError("Invalid response from server. Please check your network and try again.")

    def health(self) -> bool:
        """
        Simple reachability check for NOVA_API.
        Assumes NOVA_API exposes GET /health -> 200 OK.
        """
        url = f"{self.base_url}/health"
        try:
            r = requests.get(url, timeout=10, headers={"User-Agent": self.user_agent})
            return r.status_code == 200
        except Exception:
            return False

    def chat(self, prompt: str, context: Optional[Dict[str, str]], model: str, provider: str, repo_map: Optional[str] = None, active_file: Optional[str] = None) -> str:
        """
        Calls NOVA_API chat endpoint with optional repository map.
        """
        payload = {
            "prompt": prompt,
            "context": context or {},
            "model": model,
            "provider": provider,
            "repo_map": repo_map,
            "active_file": active_file
        }

        tried: List[str] = []
        last_error: Optional[str] = None

        for path in ("/chat", "/chat/run"):
            tried.append(path)
            resp = self._post_with_auth_retry(path, payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            if resp.status_code == 404:
                last_error = f"404 on {path}"
                continue

            resp.raise_for_status()
            data = self._parse_json(resp)

            if not data.get("success"):
                raise RuntimeError(data.get("error") or "Chat failed")

            return data.get("output", "") or ""

        raise RuntimeError(
            f"Chat endpoint not found. Tried: {', '.join(tried)}. Last error: {last_error or 'unknown'}"
        )

    def enhance_prompt(
        self,
        user_prompt: str,
        model: str,
        provider: str,
        current_enhanced_prompt: Optional[str] = None,
        edit_request: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Calls NOVA_API prompt enhancement endpoint.

        Supports:
        - initial prompt enhancement
        - revision enhancement with edit instructions
        """

        payload = {
            "user_prompt": user_prompt,
            "model": model,
            "provider": provider,
            "current_enhanced_prompt": current_enhanced_prompt,
            "edit_request": edit_request,
        }

        resp = self._post_with_auth_retry("/prompt/enhance", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        resp.raise_for_status()
        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Prompt enhancement failed")

        return data
    
    def validate_plan(
        self,
        plan_content: str,
        model: str,
        provider: str,
    ) -> Dict[str, Any]:
        """
        Calls NOVA_API plan validation endpoint.

        Returns:
            {
                "success": bool,
                "is_valid": bool,
                "improved_plan": Optional[str],
                "error": Optional[str]
            }
        """
        payload = {
            "plan_content": plan_content,
            "model": model,
            "provider": provider,
        }

        resp = self._post_with_auth_retry("/plan/validate", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        resp.raise_for_status()
        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Plan validation failed")

        return data
    
    def sync_map(self, repo_map: str) -> bool:
        """Transmits the Project Blueprint to NOVA API."""
        resp = self._post_with_auth_retry("/repo/sync-map", {"repo_map": repo_map})
        return resp.status_code == 200

    def generate_commit_message(
        self,
        diff_text: str,
        model: str,
        provider: str,
    ) -> str:
        """
        Calls NOVA_API commit message generation endpoint.

        Returns:
            str -> generated commit message
        """
        payload = {
            "diff_text": diff_text,
            "model": model,
            "provider": provider,
        }

        resp = self._post_with_auth_retry("/commit/message", payload)

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        resp.raise_for_status()
        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Commit message generation failed")

        return data.get("commit_message", "") or ""


    def refactor(self, filename: str, content: str, model: str, provider: str, repo_map: Optional[str] = None) -> str:
        resp = self._post_with_auth_retry(
            "/janitor/refactor",
            {"filename": filename, "content": content, "model": model, "provider": provider, "repo_map": repo_map},
        )

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        resp.raise_for_status()
        data = self._parse_json(resp)

        if not data.get("success"):
            raise RuntimeError(data.get("error") or "Janitor failed")

        return data.get("output", "")

    def run_with_healing(
        self,
        command: List[str],
        cwd: str,
        model: str,
        provider: str,
        exit_code: int,
        stdout: str,
        stderr: str,
        context: Optional[dict] = None,
        repo_map: Optional[str] = None,
        healing_history: Optional[List[str]] = None,
    ) -> str:
        payload = {
            "command": command,
            "cwd": cwd,
            "model": model,
            "provider": provider,
            "exit_code": exit_code,
            "stdout": stdout or "",
            "stderr": stderr or "",
            "context": context or {},
            "repo_map": repo_map,
            "healing_history": healing_history or [],
        }

        tried: List[str] = []
        last_error: Optional[str] = None

        for path in ("/healer/run", "/healer"):
            tried.append(path)
            resp = self._post_with_auth_retry(path, payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            if resp.status_code == 404:
                last_error = f"404 on {path}"
                continue

            resp.raise_for_status()
            data = self._parse_json(resp)

            if not data.get("success"):
                raise RuntimeError(data.get("error") or "Healer failed")

            return data.get("output", "") or ""

        raise RuntimeError(
            f"Healer endpoint not found. Tried: {', '.join(tried)}. Last error: {last_error or 'unknown'}"
        )
    
    def create_github_repo(
        self,
        repo_name: str,
        private: bool = False,
        description: Optional[str] = None,
    ) -> Dict[str, Any]:
            """
            Calls NOVA_API to create a GitHub repository for the authenticated user.
            """

            payload = {
                "repo_name": repo_name,
                "private": private,
                "description": description,
            }

            resp = self._post_with_auth_retry("/github/create-repo", payload)

            if resp.status_code == 401:
                raise RuntimeError("Unauthorized. Run: login")

            resp.raise_for_status()
            data = self._parse_json(resp)

            if not data.get("ok"):
                raise RuntimeError(data.get("detail") or "GitHub repo creation failed")

            return data
    def chat_stream(
        self,
        prompt: str,
        context: Optional[Dict[str, str]],
        model: str,
        provider: str,
        repo_map: Optional[str] = None,
        active_file: Optional[str] = None,
    ) -> Iterator[Dict[str, Any]]:
        """
        Calls NOVA_API streaming chat endpoint and yields SSE events.
        """
        payload = {
            "prompt": prompt,
            "context": context or {},
            "model": model,
            "provider": provider,
            "repo_map": repo_map,
            "active_file": active_file
        }

        self._require_auth()
        url = f"{self.base_url}/chat/stream"

        try:
            resp = requests.post(
                url,
                json=payload,
                headers=self._headers(),
                timeout=self.timeout,
                stream=True,
            )
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
            raise RuntimeError("Build stream interrupted. Please check your internet connection.")
        except requests.RequestException:
            raise RuntimeError("Network failure during build. Check your internet and try again.")

        if resp.status_code == 401:
            if self._try_refresh():
                try:
                    resp = requests.post(
                        url,
                        json=payload,
                        headers=self._headers(),
                        timeout=self.timeout,
                        stream=True,
                    )
                except requests.RequestException as e:
                    raise RuntimeError(f"Streaming API request failed after refresh: {e}")

        if resp.status_code == 401:
            raise RuntimeError("Unauthorized. Run: login")

        if resp.status_code >= 400:
            # Map technical status codes to clean human messages
            if resp.status_code == 403:
                msg = "Access denied. Please check your internet connection or login status."
            elif resp.status_code in (500, 502, 503, 504):
                msg = "The server is currently unreachable. Please check your internet connection and try again."
            elif resp.status_code == 401:
                msg = "Session expired. Please run 'nova login' to re-authenticate."
            elif resp.status_code == 402:
                msg = "Usage limit reached. Please upgrade your plan by visiting [bold cyan][link=https://nova.bridgeye.com/plans]https://nova.bridgeye.com/plans[/link][/bold cyan]"
            else:
                msg = "A connection error occurred. Please try again shortly."
            
            # Raise only the clean message, exposing no codes or JSON
            raise RuntimeError(msg)

        for raw_line in resp.iter_lines(decode_unicode=True):
            if not raw_line:
                continue

            line = raw_line.strip()
            if not line.startswith("data: "):
                continue

            data_str = line[len("data: "):].strip()
            if not data_str:
                continue

            try:
                yield json.loads(data_str)
            except Exception:
                yield {"type": "error", "error": f"Invalid stream payload: {data_str}"}


--- FILE: nova_core/ai/utils.py ---

# nova_cli/nova_core/ai/utils.py
# CLI-only list for model selector UI.
# The API remains the source of truth for what is actually allowed.

MODELS = {
    "groq": [
        "openai/gpt-oss-20b",
        "meta-llama/llama-4-scout-17b-16e-instruct",
        "llama-3.1-8b-instant",
    ],
    "openrouter": [
        "openai/gpt-oss-120b",
    ]
}

--- FILE: cli/__init__.py ---



--- FILE: cli/commands.py ---

# NOVA_CLI/nova_cli/cli/commands.py

def register_core_commands(registry, shell):
    
    registry.register("login", shell.cmd_login)
    registry.register("nova login", shell.cmd_login)
    # model / git
    registry.register(":model", shell.cmd_model)
    registry.register(":overdrive", shell.cmd_overdrive)
    registry.register(":exit overdrive", shell.cmd_overdrive)
    registry.register(":exitoverdrive", shell.cmd_overdrive)
    registry.register(":exit-overdrive", shell.cmd_overdrive)
    registry.register(":overdrive-exit", shell.cmd_overdrive)
    registry.register(":overdriveexit", shell.cmd_overdrive)
    registry.register(":overdrive exit", shell.cmd_overdrive)
    registry.register(":makeroot", shell.cmd_makeroot)
    registry.register(":exitroot", shell.cmd_exitroot)
    registry.register(":freeroot", shell.cmd_exitroot)
    registry.register(":exit root", shell.cmd_exitroot)
    registry.register(":free root", shell.cmd_exitroot)
    registry.register(":gitoptions", shell.cmd_gitoptions)

    # reset / unload
    registry.register(":reset", shell.cmd_reset)
    registry.register("reset", shell.cmd_reset)

    registry.register(":unload", shell.cmd_unload)
    registry.register("unload", shell.cmd_unload)

    # map / ls
    registry.register(":map", shell.cmd_map)
    registry.register("ls", shell.cmd_map)

    # explicit file ops
    registry.register(":create", shell.cmd_create)
    registry.register(":delete", shell.cmd_delete)

    # wizard / apply / paste
    registry.register(":wizard", shell.cmd_wizard)
    registry.register(":apply", shell.cmd_apply)
    registry.register(":paste", shell.cmd_paste)

    # load / navigation
    registry.register(":continue", shell.cmd_continue)
    registry.register("continue", shell.cmd_continue)
    registry.register(":load", shell.cmd_load)
    registry.register("cd", shell.cmd_cd)
    registry.register("pwd", shell.cmd_pwd)

    # execution / cleanup
    registry.register("run", shell.cmd_run)
    registry.register("clean", shell.cmd_clean)

    # build command
    registry.register("Build It", shell.cmd_build_it)
    registry.register("build it", shell.cmd_build_it)
    registry.register("Build", shell.cmd_build_it)
    registry.register("build", shell.cmd_build_it)

    # exit / help
    registry.register("exit", shell.cmd_exit)
    registry.register("quit", shell.cmd_exit)

    registry.register("help", shell.cmd_help)
    registry.register(":help", shell.cmd_help)
    registry.register("doctor", shell.cmd_doctor)

    #registry.register("logout", shell.cmd_logout)  # optional, future-proof


--- FILE: cli/main.py ---

import sys

from nova_cli.cli.shell import NovaShell
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli import config as cli_config
from nova_cli.nova_core.auth.storage import is_logged_in, has_refresh_token


def _doctor() -> int:
    api = BridgeyeAPIClient()

    print("NOVA CLI — Doctor")
    print(f"- NOVA_API_BASE_URL:  {cli_config.NOVA_API_BASE_URL}")
    print(f"- NOVA_AUTH_BASE_URL: {cli_config.NOVA_AUTH_BASE_URL}")
    print(f"- Default provider:   {cli_config.DEFAULT_PROVIDER}")
    print(f"- Default model:      {cli_config.DEFAULT_MODEL}")

    api_ok = api.health()
    print(f"- API reachable:      {'yes' if api_ok else 'no'}")

    logged_in = is_logged_in()
    print(f"- Logged in:          {'yes' if logged_in else 'no'}")

    # Helpful extra signal: refresh token presence
    rt = has_refresh_token()
    print(f"- Has refresh token:  {'yes' if rt else 'no'}")

    if not api_ok:
        print("\nFix:")
        print("  1) Start NOVA_API")
        print("  2) Or set NOVA_API_BASE_URL to the right host/port")
        return 1

    if not logged_in:
        print("\nFix:")
        print("  Run: nova login")
        return 1

    return 0


def main():
    try:
        shell = NovaShell()
    except Exception as e:
        from nova_cli.local.ui import ui
        ui.display_error(f"NOVA failed to initialize: {e}\n\nPlease report this to Bridgeye at support@bridgeye.com.")
        return

    # No args => interactive
    if len(sys.argv) == 1:
        shell.run()
        return

    cmd = sys.argv[1]
    args = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else ""

    if cmd in ("help", "--help", "-h"):
        shell.cmd_help("")
        return

    if cmd == "doctor":
        raise SystemExit(_doctor())

    if cmd == "run":
        shell.cmd_run(args)
        return

    if cmd == "clean":
        shell.cmd_clean(args)
        return

    if cmd == "login":
        shell.cmd_login(args)
        return

    # Optional legacy alias
    if cmd == "heal":
        shell.cmd_run(args)
        return

    # Anything else => treat as chat prompt (one-shot)
    prompt = " ".join(sys.argv[1:])
    shell.handle_ai_request(prompt)


if __name__ == "__main__":
    main()


--- FILE: cli/registry.py ---

# NOVA_CLI\nova_cli\cli\registry.py

class CommandRegistry:
    def __init__(self):
        self._commands = {}

    def register(self, name, handler):
        self._commands[name] = handler

    def get(self, name):
        return self._commands.get(name)

    def all(self):
        return self._commands


--- FILE: cli/shell.py ---


import logging
import os
import shlex
import signal
import sys
import time

import core.state as state
import core.prompts as prompts
from nova_cli import config
from nova_cli.cli.commands import register_core_commands
from nova_cli.cli.registry import CommandRegistry

from nova_cli.cli.shell_parts.ai_logic import ShellAILogicMixin
from nova_cli.cli.shell_parts.handlers import ShellHandlersMixin
from nova_cli.cli.shell_parts.ui_utils import ShellUIUtilsMixin

from nova_cli.local.ui import ui
from nova_cli.local.healer.runner import run_with_healing


class NovaShell(ShellHandlersMixin, ShellUIUtilsMixin, ShellAILogicMixin):
    def __init__(self):
        # --- CONFIGURE LOGGING (File only, no Console) ---
        logging.basicConfig(
            filename='nova.log',
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s'
        )
        # Prevent logs from propagating to the console (stderr)
        logging.getLogger().propagate = False
        for handler in logging.root.handlers[:]:
            if not isinstance(handler, logging.FileHandler):
                logging.root.removeHandler(handler)

        self.state = state.SessionState()
        self.interface = ui

        # API-only mode (no local model client)
        self.groq_client = None
        self.chat_session = None
        self.context_loader = None

        # Configuration
        self.provider = config.DEFAULT_PROVIDER
        self.model_name = config.DEFAULT_MODEL

        # Command Registry
        self.registry = CommandRegistry()
        register_core_commands(self.registry, self)
        
        # --- PROMPT TOOLKIT SETUP (Replaces Readline for perfect scrolling/wrapping) ---
        self.history_file = os.path.expanduser("~/.nova_history")
        
        from prompt_toolkit import PromptSession
        from prompt_toolkit.history import FileHistory
        from prompt_toolkit.completion import Completer, Completion
        
        class NovaCompleter(Completer):
            def __init__(self, registry):
                self.registry = registry

            def get_completions(self, document, complete_event):
                text = document.text_before_cursor
                if " " not in text:
                    for cmd in self.registry.all().keys():
                        if cmd.startswith(text):
                            yield Completion(cmd, start_position=-len(text))
                else:
                    import glob
                    word = text.split(" ")[-1]
                    matches = glob.glob(word + "*")
                    for m in matches:
                        display = m + ("/" if os.path.isdir(m) else "")
                        yield Completion(display, start_position=-len(word))
                        
        self.prompt_session = PromptSession(
            history=FileHistory(self.history_file),
            completer=NovaCompleter(self.registry)
        )
        
        # Load any interrupted build state from disk
        self.state.load_build_state()

    def get_prompt_text(self):
        prefix = ""
        if self.state.active_file:
            prefix = f"[dim]({os.path.basename(self.state.active_file)})[/dim] "
        if len(self.state.loaded_files) > 0:
            prefix += f"[dim][{len(self.state.loaded_files)} loaded][/dim] "
        
        # Color changes to RED in overdrive mode
        prompt_color = "bold red" if config.OVERDRIVE else "bold cyan"
        overdrive_indicator = "[bold red]O[/bold red] " if config.OVERDRIVE else ""
        
        return f"{prefix}{overdrive_indicator}[{prompt_color}]spark terminal >[/{prompt_color}]  "


    # --- COMMAND HANDLERS ---

    def run(self):
        title = "NOVA"
        
        # 1. Kernel level rename (Changes name in Activity Monitor/Task Manager)
        import ctypes
        try:
            if sys.platform == "darwin":
                libc = ctypes.CDLL(None)
                libc.setprogname(title.encode('utf-8'))
            elif sys.platform == "win32":
                ctypes.windll.kernel32.SetConsoleTitleW(title)
        except Exception: pass

        # 2. The Title Sequence (Triggers VS Code ${sequence} setting)
        def force_tab_rename():
            # \x1b]0; -> Standard code to set tab/window title
            # We write to __stdout__ to bypass any filtering by UI libraries
            sys.__stdout__.write(f"\x1b]0;{title}\x07")
            sys.__stdout__.flush()

        self.interface.clear()
        force_tab_rename()
        
        logging.info(f"Session started in {config.PROJECT_ROOT}")

        # Simple non-interactive entry
        if len(sys.argv) > 1:
            cmd = sys.argv[1]
            args = sys.argv[2:] if len(sys.argv) > 2 else []

            if cmd == "run":
                if args:
                    try:
                        output = run_with_healing(
                            command_args=args,
                            cwd=os.getcwd(),
                            model=self.model_name,
                            provider=self.provider,
                            context=self.state.loaded_files,
                            repo_map=prompts.get_repo_map_cached(os.getcwd()),
                        )
                        if output and output != "SUCCESS_SIGNAL":
                            print(output)
                    except Exception as e:
                        print(f"Healer Error: {e}")
                    return
                print("Usage: nova run <command>")
                return

            if cmd == "clean":
                # in non-interactive mode, clean requires loaded context; keep it simple for now
                print("Use interactive mode for clean (load files first).")
                return

            if cmd.lower() == "build":
                self.cmd_build_it(args)
                return

        self.interface.display_header(self.model_name, os.getcwd())
        from nova_cli.nova_core.auth.storage import is_logged_in
        self.interface.display_startup_hint(is_logged_in())

        from prompt_toolkit.formatted_text import ANSI
        
        while True:
            try:
                # Re-assert name to handle Shell Integration resets
                force_tab_rename()

                # Capture Rich formatted prompt to pass into PromptSession
                with self.interface.console.capture() as cap:
                    self.interface.console.print(self.get_prompt_text(), end="")

                cmd_raw = self.prompt_session.prompt(ANSI(cap.get())).strip()
                if not cmd_raw:
                    continue

                try:
                    parts = shlex.split(cmd_raw)
                except ValueError:
                    # Fallback for natural language containing unbalanced quotes (e.g. "How're you?")
                    parts = cmd_raw.split()

                cmd_base = parts[0]
                # FIX: We pass the raw parts[1:] list to handlers that support it, 
                # or the correctly escaped string to others.
                args = " ".join(shlex.quote(p) for p in parts[1:]) if len(parts) > 1 else ""

                ai_prompt = None

                handler = self.registry.get(cmd_base)

                if handler:
                    # Pass the joined but safely quoted string to the handler
                    result = handler(args)
                    if result == "EXIT":
                        break
                    if isinstance(result, str):
                        ai_prompt = result
                else:
                    ai_prompt = cmd_raw

                if ai_prompt:
                    # 1. Determine Intent
                    intent = self._get_nlp_intent(ai_prompt)
                    plan_exists = os.path.exists(os.path.join(os.getcwd(), "PLAN.md"))

                    # 2. Strict Architect Gate: If no PLAN.md, force 'make/create' requests to PLAN
                    creation_keywords = ["make", "create", "build", "generate", "setup", "write a"]
                    is_creation_request = any(kw in ai_prompt.lower() for kw in creation_keywords)
                    
                    # WEB BUILDER INTERCEPT:
                    # Prevent general creation keywords ("make") from triggering PLAN if it's a web request
                    web_keywords = ["website", "landing page", "web page", "web site", "frontend", "react app", "html site", "portfolio site", "ecommerce site", "web app", "web based", "site"]
                    is_web_query = any(wk in ai_prompt.lower() for wk in web_keywords)
                    
                    # DATA ANALYSIS INTERCEPT:
                    # Prevent data analysis queries from triggering PLAN and entering the prompt enhancer
                    lower_p = ai_prompt.lower()
                    data_extensions = [".csv", ".xlsx", ".parquet", ".json"]
                    is_data_query = any(ext in lower_p for ext in data_extensions)
                    
                    # Catch natural language analysis requests even if the extension isn't explicitly typed
                    if not is_data_query and (("analyse" in lower_p or "analyze" in lower_p) and "data" in lower_p):
                        is_data_query = True
                        
                    config_files = ["package.json", "tsconfig.json", "composer.json", "package-lock.json"]
                    if any(cfg in lower_p for cfg in config_files):
                        remaining_p = lower_p
                        for cfg in config_files:
                            remaining_p = remaining_p.replace(cfg, "")
                        is_data_query = any(ext in remaining_p for ext in data_extensions)
                    
                    if is_web_query or is_data_query:
                        intent = "SKILLS"
                    elif not plan_exists and is_creation_request:
                        intent = "PLAN"
                    
                    # 3. Check for Enhancement Gate
                    if self.should_use_prompt_enhancer(ai_prompt, intent):
                        enhanced = self.run_prompt_enhancement_flow(ai_prompt)
                        if not enhanced:
                            continue # User cancelled
                        
                        # Set override to proceed to Architect Phase (PLAN.md creation)
                        ai_prompt = f"SYSTEM_OVERRIDE: Based on this approved plan, create the PLAN.md:\n{enhanced}"
                        self.handle_ai_request(ai_prompt, nlp_intent="PLAN")
                    else:
                        # Direct routing for BUILD, ANALYZE, EDIT, or existing PLAN intents
                        self.handle_ai_request(ai_prompt, nlp_intent=intent)

            except KeyboardInterrupt:
                # Handle Ctrl+C: Clear the current line and return to prompt
                self.interface.print("\n[yellow]>> Operation cancelled.[/yellow]")
                continue
            except EOFError:
                with self.interface.console.status("[bold red]SHUTDOWN: Closing Nova...", spinner="line", spinner_style="red"):
                    time.sleep(0.5)
                break


--- FILE: cli/shell_parts/ai_logic.py ---

import logging
import os
import re
import sys
import time

from rich.panel import Panel

from core import prompts
from nova_cli import config
from nova_cli.cli.shell_parts.ui_utils import extract_enhanced_prompt
from nova_cli.local.contextifier.engine import run_contextify
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.utils import extract_code_from_markdown, ensure_dependencies
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient



class ShellAILogicMixin:
    
    def handle_ai_request(self, prompt_text, override_model=None, nlp_intent=None):
            """
            Orchestrates AI interaction with automated context discovery.
            """
            try:
                plan_exists = os.path.exists(os.path.join(os.getcwd(), "PLAN.md"))
                api = BridgeyeAPIClient()
                lower_p = prompt_text.lower()
                
                # --- 1. HARD HEURISTIC CHECK (Highest Priority) ---
                
                # DELETE HEURISTIC (Absolute Highest Priority)
                delete_keywords = ["delete", "remove", "rm", "erase", "wipe"]
                # Match whole words AND restrict to shorter prompts to prevent massive web-build prompts from triggering false positives
                is_delete_query = len(prompt_text.split()) < 30 and any(re.search(rf'\b{kw}\b', lower_p) for kw in delete_keywords) and bool(re.findall(r'[\w\-\/]+\.\w+', lower_p))

                # SKILLS HEURISTIC (Data files) - Ignore common config files to prevent intent hijacking
                data_extensions = [".csv", ".xlsx", ".parquet", ".json"]
                is_data_query = any(ext in lower_p for ext in data_extensions)
                
                # Exclude project config files from being treated as "Data to Analyze"
                config_files = ["package.json", "tsconfig.json", "composer.json", "package-lock.json"]
                if any(cfg in lower_p for cfg in config_files):
                    # If ONLY config files are present, it's not a data query
                    # We check if a real data extension exists outside of the config filenames
                    remaining_p = lower_p
                    for cfg in config_files:
                        remaining_p = remaining_p.replace(cfg, "")
                    is_data_query = any(ext in remaining_p for ext in data_extensions)

                # EDIT HEURISTIC: Force EDIT if a code file and modification verb are present
                mentioned_code_files = re.findall(r'\b[\w\-\/]+\.(?:py|js|html|css|ts|jsx|tsx|cpp|c|h|java|go|rs|md)\b', lower_p)
                edit_keywords = ["update", "fix", "change", "modify", "add", "refactor", "edit"]
                
                # Logic: Allow EDIT heuristic if a code file is mentioned and modification verbs are used
                is_edit_query = bool(mentioned_code_files) and any(kw in lower_p for kw in edit_keywords)

                # WEB HEURISTIC: Force SKILLS for web builds to avoid PLAN hijacking
                web_keywords = ["website", "landing page", "web page", "web site", "frontend", "react app", "html site", "portfolio site", "ecommerce site", "web app", "web based", "site"]
                is_web_query = any(wk in lower_p for wk in web_keywords)

                # REPO OVERVIEW HEURISTIC: Force ANALYZE and trigger full context load
                repo_overview_keywords = ["about the repo", "about the folder", "about this project", "overview of the folder", "overview of the repo", "explain this project", "explain the repo", "explain this folder", "repo overview", "project overview", "folder overview"]
                
                # Prevent heuristics from being hijacked by words inside our own internal prompts
                if "system_override" in lower_p:
                    is_repo_overview = False
                else:
                    is_repo_overview = any(kw in lower_p for kw in repo_overview_keywords)

                if "SYSTEM_OVERRIDE" in prompt_text:
                    # If it's an internal approved plan routing, don't let heuristics hijack it
                    intent = nlp_intent or "PLAN"
                elif is_delete_query:
                    intent = "DELETE"
                elif is_web_query:
                    intent = "SKILLS"
                elif is_data_query:
                    intent = "SKILLS"
                elif is_repo_overview:
                    intent = "ANALYZE"
                elif is_edit_query:
                    intent = "EDIT"
                else:
                    intent = nlp_intent

                # --- 2. INTENT CLASSIFICATION (Fallback) ---
                if not intent:
                    if (("analyse" in lower_p or "analyze" in lower_p) and "data" in lower_p):
                        intent = "SKILLS"
                    elif not override_model and "SYSTEM_OVERRIDE" not in prompt_text:
                        intent = self._get_nlp_intent(prompt_text)
                
                # --- 3. ARCHITECT LOCK ---
                # If no plan exists and it's not a deletion, skill, or explicit edit, force intent to PLAN.
                # This ensures creation requests properly enter the Architect phase.
                if not plan_exists and intent not in ["DELETE", "SKILLS", "GENERAL", "ANALYZE", "EDIT"]:
                    intent = "PLAN"

                intent = intent or "GENERAL"

                # --- 2. SKILLS GATE (Hard Intercept) ---
                if intent == "SKILLS":
                    skill_match = self._route_skill(prompt_text)
                    
                    if skill_match and skill_match != "NONE":
                        self._execute_skill_flow(skill_match, prompt_text, override_model)
                        return # EXIT: Ensure we never fall back to general chat models
                    else:
                        # If it was forced to SKILLS by heuristic but no skill code exists,
                        # manually force the data-analysis fallback if files are mentioned.
                        if is_data_query:
                            self._execute_skill_flow("data-analysis", prompt_text, override_model)
                            return

                # --- 4. CONTEXT DISCOVERY (Automated Context Loading) ---
                if intent in ["EDIT", "CREATE", "PLAN", "ANALYZE"]:
                    if is_repo_overview:
                        self._step_start("Scanning project architecture for comprehensive overview...")
                        self.state.loaded_files.clear()
                        self.state.loaded_paths.clear()
                        project_context = run_contextify(os.getcwd(), save_to_disk=True)
                        self.state.loaded_files.update(project_context)
                        for rel_path in project_context.keys():
                            abs_p = os.path.abspath(rel_path).replace("\\", "/")
                            self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                        self._step_ok("Project context synchronized.")
                    else:
                        mentioned_files = re.findall(r'[\w\-\/]+\.\w+', prompt_text)
                        for mf in mentioned_files:
                            # Ignore data files for text context; they are handled by skills
                            if any(mf.endswith(ext) for ext in [".csv", ".xlsx", ".parquet", ".json"]):
                                continue
                            if os.path.exists(mf):
                                self.state.active_file = os.path.abspath(mf).replace("\\", "/")
                                base = os.path.basename(self.state.active_file)
                                try:
                                    with open(self.state.active_file, "r", encoding="utf-8") as f:
                                        self.state.loaded_files[base] = f.read()
                                    self.state.loaded_paths[base] = self.state.active_file
                                except Exception:
                                    pass
                                # Load only the first relevant file mentioned as the "Active" file
                                break

                # --- 2. FILE DISCOVERY (Only after intent is known) ---
                if intent in ["EDIT", "CREATE", "ANALYZE", "SKILLS"]:
                    mentioned_files = re.findall(r'[\w\-\/]+\.\w+', prompt_text)
                    if mentioned_files:
                        for mf in mentioned_files:
                            if os.path.exists(mf):
                                self.state.active_file = os.path.abspath(mf).replace("\\", "/")
                                base = os.path.basename(self.state.active_file)
                                try:
                                    with open(self.state.active_file, "r", encoding="utf-8") as f:
                                        self.state.loaded_files[base] = f.read()
                                    self.state.loaded_paths[base] = self.state.active_file
                                except Exception:
                                    pass
                                break

                # --- BUILD REDIRECT ---
                if intent == "BUILD":
                    self.interface.print("[bold green]>> Build intent detected. Triggering implementation sequence...[/bold green]")
                    self.cmd_build_it("")
                    return

                # --- SKILL ROUTER INTERCEPT ---
                if intent == "SKILLS":
                    skill_match = self._route_skill(prompt_text)
                    if skill_match and skill_match != "NONE":
                        self._execute_skill_flow(skill_match, prompt_text, override_model)
                        return
                    else:
                        self.interface.print("[yellow]>> No matching skill found. Falling back to GENERAL intent.[/yellow]")
                        intent = "GENERAL"

                # --- LOCAL DELETE INTERCEPT (Bypass AI API entirely) ---
                if intent == "DELETE":
                    targets = []
                    for word in prompt_text.split():
                        clean_word = word.strip("',.\"")
                        # STRICT MATCH: Only target files that actually exist on disk.
                        # This prevents accidental deletion attempts on version numbers (18.3.1), CSS classes (px-1.5), or JS snippets (window.X)
                        if os.path.exists(clean_word) and os.path.isfile(clean_word):
                            if clean_word not in targets:
                                targets.append(clean_word)
                    
                    if targets:
                        self.interface.print(f"[cyan]>> Direct Action: Executing local delete for {', '.join(targets)}[/cyan]")
                        fake_output = "\n".join([f"[DELETE: {t}]" for t in targets])
                        
                        modified_files = handle_ai_commands(fake_output)
                        
                        if isinstance(modified_files, list) and modified_files:
                            for fpath in modified_files:
                                if fpath.startswith("DELETED:"):
                                    del_path = fpath.split("DELETED:", 1)[1].strip()
                                    abs_del = os.path.abspath(del_path).replace("\\", "/")
                                    base_del = os.path.basename(abs_del)
                                    
                                    if base_del in self.state.loaded_files:
                                        del self.state.loaded_files[base_del]
                                    if base_del in self.state.loaded_paths:
                                        del self.state.loaded_paths[base_del]
                                    if self.state.active_file == abs_del:
                                        self.state.active_file = None
                                        
                                    self.interface.print(f"[dim]>> Removed {base_del} from AI context.[/dim]")
                            
                            # Trigger full context regeneration after successful local deletions
                            if any(f.startswith("DELETED:") for f in modified_files):
                                self._step_start("Rebuilding project context after deletion...")
                                self.state.loaded_files.clear()
                                self.state.loaded_paths.clear()
                                
                                project_context = run_contextify(os.getcwd(), save_to_disk=True)
                                self.state.loaded_files.update(project_context)
                                for rel_path in project_context.keys():
                                    abs_p = os.path.abspath(rel_path).replace("\\", "/")
                                    self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                                    
                                self._step_ok("Context updated. project_context.txt regenerated.")
                        return
                    else:
                        self.interface.print("[yellow]>> Delete intent recognized, but no valid target file could be identified in the prompt.[/yellow]")
                        return
                # --- 2. MODEL & PROMPT CONFIGURATION ---
                target_model = override_model if override_model else self.model_name
                target_provider = self.provider

                # Implementation Gate: Kimi is the BUILDER. Groq 120B is the ARCHITECT.
                # PLAN intent MUST use the Groq 120B model to create PLAN.md.
                # EDIT and BUILD intents use KIMI K2.6.
                if (intent in ["EDIT", "BUILD"] or "PHASE: IMPLEMENTATION" in prompt_text) and intent != "PLAN":
                    target_model = "moonshotai/kimi-k2.6"
                    target_provider = "openrouter"
                    
                    # [FLOWCHART STEP 3]: Every time such event is triggered, whole project is first contextified.
                    self._step_start(f"Synchronizing project context for {intent}...")
                    if os.path.exists("project_context.txt"):
                        try:
                            os.remove("project_context.txt")
                        except Exception:
                            pass
                    
                    self.state.loaded_files.clear()
                    self.state.loaded_paths.clear()

                    def log_file(p):
                        if len(self.state.loaded_files) % 5 == 0:
                            self.interface.print(f"[dim]  Scanning: {p}[/dim]", soft_wrap=True)
                    
                    # [FLOWCHART STEP 4]: Latest contextified version of project is generated.
                    project_context = run_contextify(os.getcwd(), verbose_callback=log_file, save_to_disk=True)
                    self.state.loaded_files.update(project_context)
                    for rel_path in project_context.keys():
                        abs_p = os.path.abspath(rel_path).replace("\\", "/")
                        self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                    
                    # Force-sync the active file to ensure the AI's immediate target is fresh.
                    if self.state.active_file and os.path.exists(self.state.active_file):
                        base = os.path.basename(self.state.active_file)
                        try:
                            with open(self.state.active_file, "r", encoding="utf-8") as f:
                                self.state.loaded_files[base] = f.read()
                            self.state.loaded_paths[base] = self.state.active_file
                        except Exception:
                            pass
                    
                    self._step_ok(f"Context verified. NOVA is now ready to identify and fix the code...")
                    self.interface.display_coding_mode(target_model)
                else:
                    # ANALYSIS / Default
                    target_model = override_model or self.model_name
                
                # Refresh and Fetch Map for Peripheral Vision
                prompts.clear_file_tree_cache()
                repo_map = prompts.get_repo_map_cached(os.getcwd())

                # Inject Map into dynamic system instructions based on NLP intent
                if not override_model and "SYSTEM_OVERRIDE" not in prompt_text:
                    if intent == "EDIT":
                        # [FLOWCHART STEP 5]: Kimi K2.6 will take full context and give SEARCH REPLACE blocks.
                        phase_instruction = "YOU ARE IN SURGICAL EDIT MODE. PHASE: IMMEDIATE IMPLEMENTATION."
                        task_instruction = (
                            "1. Identify the relevant code bits across the project context.\n"
                            "2. Provide surgical [EDIT] tags immediately.\n"
                            "3. YOU MUST use the SEARCH/REPLACE block syntax for all updates.\n"
                            "4. Ensure your SEARCH block matches the project context exactly."
                        )
                    elif intent == "PLAN":
                        phase_instruction = "YOU ARE IN THE ARCHITECT & PLANNING PHASE."
                        # Force the model to output the PLAN.md file using the Nova protocol
                        task_instruction = (
                            "1. Your goal is to write a comprehensive, implementation-ready PLAN.md based on the user request.\n"
                            "2. CRITICAL: DO NOT generate any [EDIT] blocks or application code. Your ONLY output must be the PLAN.md file.\n"
                            "3. You MUST output the plan using the exact tag: [CREATE: PLAN.md] followed by a triple-backtick Markdown code block.\n"
                            "4. TECHNOLOGY RULE: If the request is web-based, you MUST use HTML, CSS, and JS unless specifically told otherwise.\n"
                            "5. The plan must include: Objective, Architecture, File Structure, and Implementation Steps."
                        )
                    else:
                        # ANALYZE / GENERAL / READ / Default
                        phase_instruction = f"YOU ARE IN {intent} MODE."
                        if is_repo_overview:
                            task_instruction = (
                                "1. Analyze the provided REPOSITORY_MAP and full project context.\n"
                                "2. Provide a beautiful, highly detailed summary of the repository/folder.\n"
                                "3. Explain what files are present, the overall architecture, how components interconnect, and their relationships.\n"
                                "4. Use rich markdown formatting (headers, bullet points, bold text) to make it visually appealing and easy to digest."
                            )
                        else:
                            task_instruction = "1. Answer the user's question directly. Use the REPOSITORY_MAP to explain structure or logic."

                    prompt_text = (
                        f"SYSTEM_INSTRUCTION: {phase_instruction}\n"
                        f"REPOSITORY_MAP:\n{repo_map}\n\n"
                        f"{task_instruction}\n"
                        "2. DO NOT use native tool-calling. Use ONLY [CREATE], [EDIT], [MKDIR], [DELETE] tags if file changes are required.\n"
                        "3. CRITICAL: For [EDIT] or [CREATE], always follow with a Markdown code block.\n\n"
                        f"USER_REQUEST: {prompt_text}"
                    )

                # --- 3. API EXECUTION (STREAMING) ---
                # Technical logs are only shown for Coding or Build tasks per flowchart
                if intent in ["EDIT", "CREATE", "BUILD"] or "PHASE: IMPLEMENTATION" in prompt_text:
                    self._step_start(f"Transmitting context to {target_model}...")
                    total_bytes = sum(len(v) for v in self.state.loaded_files.values())
                    self.interface.print(f"[dim]  Payload size: {total_bytes / 1024:.1f} KB[/dim]")
                    self.interface.print(f"[cyan]>> {target_model} is reasoning (Thinking)...[/cyan]")

                # Switch to Streaming Response to show the Thinking Process
                active_basename = os.path.basename(self.state.active_file) if self.state.active_file else None
                
                # Disable thinking display for general conversation or analysis
                display_reasoning = intent not in ["GENERAL", "ANALYZE"]
                
                output = self.interface.stream_rich_response(
                    api.chat_stream(
                        prompt=prompt_text,
                        context=self.state.loaded_files,
                        model=target_model,
                        provider=target_provider,
                        repo_map=repo_map,
                        active_file=active_basename
                    ),
                    show_reasoning=display_reasoning
                )

                if not output:
                    self._step_fail("No response received from AI.")
                    return

                self.state.last_ai_response = output

                # Buffer extracted code for the ':apply' command
                extracted_code = extract_code_from_markdown(output)
                if extracted_code:
                    self.state.last_generated_code = extracted_code

                # --- 4. LOCAL STATE SYNCHRONIZATION ---
                # Execute [CREATE], [EDIT], [MKDIR], [DELETE] tags.
                modified_files = handle_ai_commands(output)

                # If the AI modified or created files, reload them into the persistent CLI context immediately.
                if isinstance(modified_files, list) and modified_files:
                    for fpath in modified_files:
                        if fpath == "SYSTEM_ENVIRONMENT": 
                            continue
                                
                            # Handle Context Eviction for Deleted Files
                        if fpath.startswith("DELETED:"):
                            del_path = fpath.split("DELETED:", 1)[1].strip()
                            abs_del = os.path.abspath(del_path).replace("\\", "/")
                            base_del = os.path.basename(abs_del)
                                
                            if base_del in self.state.loaded_files:
                                del self.state.loaded_files[base_del]
                            if base_del in self.state.loaded_paths:
                                del self.state.loaded_paths[base_del]
                            if self.state.active_file == abs_del:
                                    self.state.active_file = None
                                    
                            self.interface.print(f"[dim]>> Removed {base_del} from AI context.[/dim]")
                            continue
                            
                        # Automated Discovery Sync: Normalize and track absolute paths
                        abs_path = os.path.abspath(fpath).replace("\\", "/")
                        if os.path.exists(abs_path):
                            self.state.active_file = abs_path
                            base = os.path.basename(abs_path)
                            try:
                                with open(abs_path, "r", encoding="utf-8") as f:
                                    content = f.read()
                                    self.state.loaded_files[base] = content
                                    # CRITICAL: Map basename to absolute path for future Turn logic
                                    self.state.loaded_paths[base] = abs_path
                            except Exception:
                                pass

                    # --- POST-EDIT SYNC & EXECUTION ---
                    # [FLOWCHART STEP 6]: Edits/Updates/Adds what user wants and automatically runs the file.
                    if intent in ["EDIT", "CREATE"]:
                        self._step_start("Edits applied. Refreshing project context...")
                        # Immediately regenerate project_context.txt to reflect the new state of the code.
                        new_context = run_contextify(os.getcwd(), save_to_disk=True)
                        self.state.loaded_files.update(new_context)
                        self._step_ok("Project context updated.")

                        if self.state.active_file:
                            self.interface.print(f"[bold green]>> Edits applied successfully. Automatically running {os.path.basename(self.state.active_file)}...[/bold green]")
                            self.cmd_run(self.state.active_file)

                    # --- PLAN COMPLETION GUIDANCE ---
                    # Tell the user exactly what to do next so they don't get stuck
                    if intent == "PLAN":
                        plan_check = os.path.join(os.getcwd(), "PLAN.md")
                        if os.path.exists(plan_check):
                            self.interface.print("\n[bold green]✓ PLAN.md created.[/bold green]")
                            self.interface.print("[cyan]>> Type [bold]build it[/bold] to start implementation with Kimi K2.6.[/cyan]\n")

                logging.info(f"Interaction | Model: {target_model} | Output Len: {len(output)}")
                
                # Draw a divider after the conversation finishes
                self.interface.console.rule(style="dim")

            except Exception as e:
                # Display only the pretty panel to the user
                self.interface.display_error(str(e))
                
                # Record the technical details silently in the background file
                with open("nova.log", "a", encoding="utf-8") as f:
                    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
                    f.write(f"{timestamp} - ERROR - Chat API Error: {str(e)}\n")

    def handle_ai_request_streaming_build(self, prompt_text, plan_content, override_model=None):
            """
            Hybrid build path:
            1. Streams the implementation overview (visual process).
            2. Automatically extracts the file list from that stream.
            3. Builds files one-by-one to prevent 504 timeouts.
            """
            try:
                api = BridgeyeAPIClient()
                target_model = override_model or "moonshotai/kimi-k2.6"
                target_provider = "openrouter"
                self.interface.display_coding_mode(target_model)

                # --- PHASE 1: OVERVIEW STREAM ---
                self.interface.print("[bold cyan]NOVA is planning the build sequence...[/bold cyan]")
                
                # Refresh Peripheral Vision so AI sees existing files
                prompts.clear_file_tree_cache()
                repo_map = prompts.get_repo_map_cached(os.getcwd())

                overview_prompt = (
                    "PHASE: IMPLEMENTATION_OVERVIEW.\n"
                    f"CURRENT PROJECT STRUCTURE:\n{repo_map}\n\n"
                    "TASK: Briefly describe your implementation strategy and provide a list of ALL files required by PLAN.md.\n"
                    "CRITICAL: If files already exist in the CURRENT PROJECT STRUCTURE, they must still be included in the list if they are part of the plan.\n"
                    "STRICT FORMAT FOR FILE LIST:\n"
                    "Every file you intend to create must be on its own line like this: [FILE] path/to/file.ext\n\n"
                    f"PLAN.md:\n{plan_content}"
                )

                # Use the beautified streaming UI component
                try:
                    full_overview = self.interface.stream_rich_response(
                        api.chat_stream(overview_prompt, self.state.loaded_files, target_model, target_provider)
                    )
                except RuntimeError as e:
                    self.interface.display_error(f"{str(e)}\n\nCheck your internet and type [bold cyan]build it[/bold cyan] to try again.")
                    return

                # --- PHASE 2: EXTRACT FILE LIST (Fail-Safe Extraction) ---
                # 1. High-Priority: Explicit Tags (We trust these 100%)
                tagged_files = re.findall(r'\[FILE\]\s*([\w\-\/\.]+)', full_overview)
                tagged_files.extend(re.findall(r'\[CREATE:\s*([\w\-\/\.]+)\]', full_overview))

                # 2. Low-Priority Fallback: Markdown lists
                fallback_files = []
                lines = full_overview.splitlines()
                for line in lines:
                    line = line.strip()
                    # Matches "- path/file.ext" or "1. path/file.ext"
                    m = re.search(r'(?:^[-*]\s*|^\d+\.\s*)([\w\-\/\.]+\.\w+)', line)
                    if m:
                        fallback_files.append(m.group(1))

                # 3. Intelligent Filtering for Fallbacks
                # We only filter the fallback list to avoid technical terms like "Node.js"
                ignore_list = {"node.js", "express.js", "npm", "github", "express", "v14", "v16", "v18", "v20"}
                filtered_fallbacks = [
                    f for f in fallback_files 
                    if f.lower() not in ignore_list and not f.lower().endswith(('.md', '.txt'))
                ]

                # 4. Combine and Deduplicate
                # We prioritize tagged files, then add unique filtered fallbacks
                final_list = []
                seen = set()
                for f in (tagged_files + filtered_fallbacks):
                    f_norm = f.strip().replace("\\", "/")
                    f_low = f_norm.lower()

                    # CRITICAL: Ignore version numbers (e.g., 1.0, 2.4.1)
                    if re.match(r'^\d+(\.\d+)+$', f_norm):
                        continue

                    if f_low not in seen:
                        final_list.append(f_norm)
                        seen.add(f_low)
                
                files_to_build = final_list

                if not files_to_build:
                    self._step_fail("No files identified for build. Please check implementation overview.")
                    return

                self._step_ok(f"Identified {len(files_to_build)} unique files to build sequentially.")
                
                # Save build state for potential :continue resumption
                self.state.pending_build_files = files_to_build
                self.state.current_plan_content = plan_content
                self.state.save_build_state()

                # Trigger the shared execution loop with the selected model
                self._execute_build_loop(override_model=target_model)

            except Exception as e:
                self.interface.print(f"[bold red]Build API Error:[/bold red] {e}")
                logging.error(f"Hybrid Build Error: {e}")
    
    def should_use_prompt_enhancer(self, prompt_text: str, intent: str) -> bool:
            """
            Mandatory gate for the Architect phase.
            """
            plan_exists = os.path.exists(os.path.join(os.getcwd(), "PLAN.md"))

            # Skip for system internal calls or very short inputs
            if "SYSTEM_OVERRIDE" in prompt_text or len(prompt_text.strip()) < 5:
                return False

            # If we are explicitly building, we don't enhance the prompt
            if intent == "BUILD":
                return False

            # RULE: If no PLAN.md exists, any intent to CREATE or PLAN must go through the Enhancer
            if not plan_exists and intent in ["PLAN", "CREATE"]:
                return True

            # RULE: If user explicitly asks for a re-plan
            if intent == "PLAN":
                replan_keywords = ["new plan", "redo plan", "update plan", "replan", "start over", "scrap the plan"]
                if any(kw in prompt_text.lower() for kw in replan_keywords):
                    return True

            return False

    def run_prompt_enhancement_flow(self, user_prompt: str) -> str | None:
            """
            Enhances the user's raw prompt before execution.
            Allows the user to:
            1. approve
            2. suggest edits
            3. cancel

            Returns:
                - final approved prompt as str
                - None if cancelled or failed
            """
            api = BridgeyeAPIClient()

            current_enhanced_prompt = None
            edit_request = None

            while True:
                try:
                    with self.interface.create_loader(
                        "Enhancing and completing your instructions to make them more executable..."
                    ):
                        response = api.enhance_prompt(
                            user_prompt=user_prompt,
                            model=self.model_name,
                            provider=self.provider,
                            current_enhanced_prompt=current_enhanced_prompt,
                            edit_request=edit_request,
                        )
                except Exception as e:
                    self.interface.print(f"[bold red]Prompt Enhancer Error:[/bold red] {e}")
                    return None

                enhanced_prompt = extract_enhanced_prompt((response or {}).get("enhanced_prompt", ""))
                if not enhanced_prompt:
                    self.interface.print("[bold red]Prompt Enhancer Error:[/bold red] Empty enhanced prompt received.")
                    return None

                self.interface.print("\n[bold cyan]Enhanced Prompt:[/bold cyan]")
                self.interface.print(Panel(enhanced_prompt, border_style="cyan"))

                self.interface.print("\n[bold yellow]Choose an option:[/bold yellow]")
                self.interface.print("[green]1.[/green] Approve and continue")
                self.interface.print("[green]2.[/green] Suggest edits")
                self.interface.print("[green]3.[/green] Cancel")

                choice = self.interface.input("[cyan]Enter choice > [/cyan]").strip()

                if choice == "1":
                    return enhanced_prompt

                if choice == "3":
                    self.interface.print("[dim]Prompt enhancement cancelled.[/dim]")
                    return None

                if choice == "2":
                    edit_request = self.interface.input(
                        "[cyan]Enter what you want changed in the enhanced prompt > [/cyan]"
                    ).strip()

                    if not edit_request:
                        self.interface.print("[yellow]No edit request entered. Showing current enhanced prompt again.[/yellow]")
                        edit_request = None
                        continue

                    current_enhanced_prompt = enhanced_prompt
                    continue

                self.interface.print("[yellow]Invalid choice. Please enter 1, 2, or 3.[/yellow]")
                edit_request = None

    def _get_nlp_intent(self, prompt_text: str) -> str:
                """High-fidelity intent classification to drive the Architect/Builder flow."""
                api = BridgeyeAPIClient()
                
                plan_path = os.path.join(os.getcwd(), "PLAN.md")
                plan_exists = os.path.exists(plan_path)
                
                classifier_prompt = (
                    "SYSTEM: You are a strict intent classifier for NOVA, a terminal-native AI coding assistant. "
                    "Output EXACTLY one word from the allowed set. No explanation. No punctuation. No extra text. "
                    "Any deviation is a critical failure.\n\n"
                    "ALLOWED OUTPUT VALUES: PLAN | BUILD | EDIT | ANALYZE | SKILLS | DELETE | GENERAL\n\n"

                    "=== ABSOLUTE GATE — READ BEFORE ANYTHING ELSE ===\n"
                    f"PLAN_MD_EXISTS={plan_exists}\n"
                    "RULE A: If PLAN_MD_EXISTS=False → BUILD is IMPOSSIBLE. Never output BUILD.\n"
                    "RULE B: If PLAN_MD_EXISTS=False AND the user is asking for anything to be "
                    "created, built, written, made, or developed → output PLAN. No exceptions.\n"
                    "RULE C: The 'CREATE' intent is deprecated. Use 'PLAN' for all new creation requests.\n\n"

                    "=== CATEGORY DEFINITIONS ===\n\n"

                    "PLAN [creation intent]:\n"
                    "  Trigger for ANY request where the user wants a NEW project, app, tool, script, feature, "
                    "component, or system. Even if files exist in the directory, if the user says 'make me a X' "
                    "and X is a feature or app, it is a PLAN intent.\n"
                    "  Trigger on ANY of these words or phrases:\n"
                    "    make, create, build, write, develop, generate, design, scaffold, set up, initialize,\n"
                    "    start, I want, I need, I'd like, give me, help me make, help me create, help me build,\n"
                    "    can you make, can you create, can you write, can you build, please make, please create,\n"
                    "    please build, please write, put together, come up with, draft, architect, spin up,\n"
                    "    bootstrap, produce, implement a new, code a new.\n"
                    "  CRITICAL: If the user says 'Make me a [something]', ALWAYS output PLAN.\n\n"

                    "BUILD [execution intent — PLAN.md must exist]:\n"
                    "  Trigger ONLY when PLAN_MD_EXISTS=True AND user explicitly wants to execute the existing plan.\n"
                    "  Trigger phrases: build it, implement, implement the plan, execute, execute the plan, "
                    "run the plan, start building, code it up, develop it, ship it, go ahead, let's go, "
                    "start now, do it, proceed, kick it off.\n"
                    "  HARD RULE: PLAN_MD_EXISTS=False → output PLAN instead. Always.\n\n"

                    "EDIT [modification intent — file already exists]:\n"
                    "  Trigger when user wants to change, fix, update, or refactor an EXISTING file.\n"
                    "  Strong signals: a specific filename is mentioned (e.g. app.py, server.js), OR verbs like\n"
                    "    fix, change, update, modify, rename, refactor, replace, patch, adjust, tweak, rewrite, improve.\n"
                    "  GUARD: 'fix a bug', 'remove a bug', 'clean up my code' = EDIT not DELETE.\n"
                    "  GUARD: no filename mentioned + creation verb = PLAN not EDIT.\n\n"

                    "ANALYZE [question intent — no file changes]:\n"
                    "  Trigger when user asks a question or wants explanation, review, or understanding only.\n"
                    "  Trigger phrases: explain, how does, what does, why is, summarize, review, audit, describe,\n"
                    "    walk me through, understand, what is happening, is this correct, does this look right,\n"
                    "    tell me about, break down, what should I.\n\n"

                    "SKILLS [data/pipeline intent]:\n"
                    "  Trigger when user wants to process a file, run a data pipeline, or invoke a skill module.\n"
                    "  Trigger phrases: analyze data, process dataset, run skill, load dataset, parse file,\n"
                    "    run pipeline, extract from file.\n"
                    "  Also trigger when a .csv, .xlsx, .parquet, or .json file is explicitly mentioned.\n\n"

                    "DELETE [removal intent — explicit file target]:\n"
                    "  Trigger ONLY for explicit requests to remove files or directories.\n"
                    "  Trigger phrases: delete [file], remove [file], erase, wipe, drop [file].\n"
                    "  GUARD: 'remove a bug' or 'clean up code' = EDIT not DELETE.\n\n"

                    "GENERAL [fallback — no clear actionable intent]:\n"
                    "  Trigger for greetings, thanks, small talk, or purely factual questions.\n"
                    "  IMPORTANT: Do NOT default to GENERAL if the request involves creating or modifying code.\n\n"

                    "=== CONFLICT RESOLUTION PRIORITY (top-down, stop at first match) ===\n"
                    "P0. PLAN_MD_EXISTS=False + any creation/build/make intent → PLAN. No exceptions.\n"
                    "P1. PLAN_MD_EXISTS=True + explicit execution phrase ('build it', 'implement', 'go ahead') → BUILD.\n"
                    "P2. Explicit file deletion target → DELETE.\n"
                    "P3. Specific existing filename + modification verb → EDIT.\n"
                    "P4. Any creation/making/writing of something new (no existing filename) → PLAN.\n"
                    "P5. Question, explanation, or review request → ANALYZE.\n"
                    "P6. Data file or pipeline mentioned → SKILLS.\n"
                    "P7. Default → GENERAL.\n\n"

                    "=== FEW-SHOT EXAMPLES (ground truth — override definitions if conflict) ===\n\n"

                    "PLAN_MD_EXISTS=False | 'Make me a calculator app in Python' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Make me a single file python calculator in gui. All buttons should be functional.' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Create a REST API with Flask that handles user auth' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Build a to-do list app with a dark theme' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Write me a web scraper for Amazon prices' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'I want a CLI tool that converts CSV to JSON' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'I need a dashboard that shows sales data' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Give me a Python script that monitors CPU usage' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Can you make a chatbot with memory?' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Develop a login system with JWT auth' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Set up a FastAPI backend with CRUD operations' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Design a task manager app with a React frontend' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Build it' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Build a snake game' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Implement a sorting algorithm visualizer' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Please write a REST client for the GitHub API' -> PLAN\n"
                    "PLAN_MD_EXISTS=False | 'Put together a Tkinter GUI calculator' -> PLAN\n"
                    "PLAN_MD_EXISTS=True  | 'Build it' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Implement the plan' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Start building' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Execute the plan' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Go ahead and build' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Let\\'s go' -> BUILD\n"
                    "PLAN_MD_EXISTS=True  | 'Do it' -> BUILD\n"
                    "PLAN_MD_EXISTS=False | 'Fix the login bug in auth.py' -> EDIT\n"
                    "PLAN_MD_EXISTS=True  | 'Change the button color in app.py' -> EDIT\n"
                    "PLAN_MD_EXISTS=False | 'Refactor the database connection logic in db.py' -> EDIT\n"
                    "PLAN_MD_EXISTS=False | 'Update the API endpoint in routes.py' -> EDIT\n"
                    "PLAN_MD_EXISTS=False | 'How does the authentication flow work?' -> ANALYZE\n"
                    "PLAN_MD_EXISTS=False | 'Explain what this function does' -> ANALYZE\n"
                    "PLAN_MD_EXISTS=False | 'What does shell.py do?' -> ANALYZE\n"
                    "PLAN_MD_EXISTS=False | 'Analyze this CSV and extract the top 10 rows' -> SKILLS\n"
                    "PLAN_MD_EXISTS=False | 'Process my sales.xlsx file' -> SKILLS\n"
                    "PLAN_MD_EXISTS=False | 'Delete the old config.py file' -> DELETE\n"
                    "PLAN_MD_EXISTS=False | 'Remove utils.py' -> DELETE\n"
                    "PLAN_MD_EXISTS=False | 'Hey, how are you?' -> GENERAL\n"
                    "PLAN_MD_EXISTS=False | 'Thanks!' -> GENERAL\n"
                    "PLAN_MD_EXISTS=False | 'What is Python?' -> GENERAL\n\n"

                    "=== OUTPUT INSTRUCTION ===\n"
                    "Respond with EXACTLY one word. Allowed: PLAN, BUILD, EDIT, ANALYZE, SKILLS, DELETE, GENERAL.\n"
                    "Any response that is not one of these exact words is a critical failure.\n\n"

                    f"PLAN_MD_EXISTS={plan_exists} | USER_REQUEST: {prompt_text}"
                )
                try:
                    # Use the currently selected model and provider for intent classification
                    response = api.chat(
                        prompt=classifier_prompt,
                        context={},
                        model=self.model_name,
                        provider=self.provider,
                        repo_map=""
                    )
                    return response.strip().upper()
                except Exception:
                    return "PLAN"

    def _execute_build_loop(self, override_model=None):
                """Core incremental generator loop with state tracking and 429 retries."""
                api = BridgeyeAPIClient()
                target_model = override_model or "moonshotai/kimi-k2.6"
                target_provider = "openrouter"
                
                # Use a copy for safe iteration while modifying original state
                files_to_process = list(self.state.pending_build_files)
                total_files = len(files_to_process)
                final_built_files = []

                for i, fpath in enumerate(files_to_process, 1):
                    # Safety Check: Skip invalid "filenames" that are just numbers or known false positives
                    if re.match(r'^\d+(\.\d+)+$', fpath) or fpath.lower() in ["node.js", "express.js", "npm"]:
                        if fpath in self.state.pending_build_files:
                            self.state.pending_build_files.remove(fpath)
                        continue

                    # Rate-limit prevention: Small pause between high-compute requests
                    if i > 1:
                        time.sleep(2)

                    file_output = ""
                    max_retries = 5 

                    for attempt in range(max_retries):
                        try:
                            self.interface.print(f"\n[bold cyan]─── [{i}/{total_files}] Constructing: {fpath} ───[/bold cyan]")
                            
                            build_prompt = (
                                f"PHASE: IMPLEMENTATION. Generate the complete code for: {fpath}\n"
                                f"PLAN CONTEXT:\n{self.state.current_plan_content}\n"
                                f"STRICT: Output ONLY the [CREATE: {fpath}] tag followed by a Markdown code block.\n"
                                "CRITICAL: DO NOT use '<<<<<<< SEARCH', '=======', or '>>>>>>> REPLACE' markers. Output the raw, full file content only.\n"
                                "CRITICAL: DO NOT include docstrings, comments, thoughts, or explanations. The resulting file must contain ONLY valid, functional code for the target language."
                            )
                            
                            # Use stream_rich_response instead of loader to preserve Kimi's Thinking process
                            file_output = self.interface.stream_rich_response(
                                api.chat_stream(
                                    prompt=build_prompt,
                                    context=self.state.loaded_files,
                                    model=target_model,
                                    provider=target_provider
                                )
                            )
                            
                            if file_output:
                                break

                        except RuntimeError as e:
                            if any(err in str(e) for err in ["429", "timeout", "504", "internet", "connection"]) and attempt < max_retries - 1:
                                # Exponential backoff: 5s, 10s, 20s, 40s
                                wait_time = 5 * (2 ** attempt)
                                self.interface.print(f"[yellow]>> Connection unstable. Retrying in {wait_time}s... ({attempt+1}/{max_retries})[/yellow]")
                                time.sleep(wait_time)
                                continue
                            
                            # Final failure after retries
                            self.interface.display_error(
                                "Build Interrupted: Check your internet connection. \nType [bold cyan]continue[/bold cyan] to resume building from this file.", 
                                title="Connection Failure"
                            )
                            return

                    # Process the output
                    modified = handle_ai_commands(file_output)
                    
                    if modified:
                        final_built_files.append(fpath)
                        self.scan_and_load_context(fpath)
                        self._step_ok(f"Synced {fpath} to project.")
                        # Remove from state ONLY after successful sync
                        if fpath in self.state.pending_build_files:
                            self.state.pending_build_files.remove(fpath)
                            self.state.save_build_state() # PERSIST PROGRESS
                    else:
                        self._step_warn(f"Warning: {fpath} parser error (tag mismatch).")

                if not self.state.pending_build_files:
                    self._step_ok("Build complete. All modules synchronized.")
                    
                    # 1. Update Context Post-Build
                    self._step_start("Contextifying project after build...")
                    self.state.loaded_files.clear()
                    self.state.loaded_paths.clear()
                    
                    project_context = run_contextify(os.getcwd(), save_to_disk=True)
                    self.state.loaded_files.update(project_context)
                    for rel_path in project_context.keys():
                        abs_p = os.path.abspath(rel_path).replace("\\", "/")
                        self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                    self._step_ok("Context updated. project_context.txt regenerated.")
                    
                    # 2. Auto-Run What Nova Built (Intelligent Entry Point Identification)
                    entry_point = None
                    
                    if final_built_files:
                        try:
                            # Use the Architect model to analyze the plan and the files we actually created
                            with self.interface.create_loader("Identifying project entry point..."):
                                id_prompt = (
                                    "TASK: Identify the single primary entry point file (the one that starts the application) "
                                    "from the list of files provided below, using the provided PLAN.md as reference.\n\n"
                                    "RULES:\n"
                                    "1. Output ONLY the filename (e.g., 'start.py' or 'src/main.js').\n"
                                    "2. No explanation, no quotes, no markdown.\n"
                                    "3. The file must exist in the provided list.\n\n"
                                    f"FILES BUILT: {', '.join(final_built_files)}\n\n"
                                    f"PLAN.md:\n{self.state.current_plan_content}"
                                )
                                
                                ai_id = api.chat(
                                    prompt=id_prompt,
                                    context={},
                                    model=self.model_name,
                                    provider=self.provider
                                )
                                
                                candidate = ai_id.strip().replace('"', '').replace("'", "")
                                
                                # Validate the AI returned a file we actually built
                                if candidate in final_built_files:
                                    entry_point = candidate
                                else:
                                    # Try a basename match as a safety fallback
                                    for f in final_built_files:
                                        if os.path.basename(f) == os.path.basename(candidate):
                                            entry_point = f
                                            break
                        except Exception:
                            entry_point = None

                    # Fallback to hardcoded defaults if AI identification failed or was skipped
                    if not entry_point:
                        for candidate in ["main.py", "app.py", "index.js", "server.js", "run.py"]:
                            if candidate in self.state.loaded_files:
                                entry_point = candidate
                                break
                    
                    # Final Fallback to active file
                    if not entry_point and self.state.active_file:
                        entry_point = os.path.basename(self.state.active_file)
                    
                    if entry_point:
                        self.interface.print(f"\n[bold green]>> Build finished. Identified Entry Point: {entry_point}[/bold green]")
                        self.cmd_run(entry_point)
                    else:
                        self.interface.print("\n[bold green]>> Build finished. Use 'run <filename>' or 'run <command>' to execute.[/bold green]")

    def _route_skill(self, prompt_text: str) -> str | None:
                """Determines which skill to trigger based on installed package components."""
                lower_p = prompt_text.lower()
                
                data_extensions = [".csv", ".xlsx", ".parquet", ".json"]
                is_data = any(ext in lower_p for ext in data_extensions)
                
                # Security: Do not treat project configs as data files
                config_files = ["package.json", "tsconfig.json", "composer.json", "package-lock.json"]
                if any(cfg in lower_p for cfg in config_files):
                    remaining_p = lower_p
                    for cfg in config_files:
                        remaining_p = remaining_p.replace(cfg, "")
                    is_data = any(ext in remaining_p for ext in data_extensions)

                # 1. Direct Route for Data (Highest Priority - Bypass folder check)
                if is_data:
                    return "data-analysis"

                # 2. Resolve Skills Directory Path (Absolute)
                # Check standard installation location
                project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                skills_dir = os.path.join(project_root, "skills")
                
                # Fallback for editable install/standard package layout
                if not os.path.exists(skills_dir):
                    # Check if skills is inside nova_cli (some distribution styles)
                    skills_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "skills")

                available_skills = []
                if os.path.exists(skills_dir):
                    available_skills = [d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))]

                if not available_skills:
                    return None

                # 2. Stage 1 — Web Build Gatekeeper
                api = BridgeyeAPIClient()
                if "frontend-web" in available_skills:
                    gate_prompt = (
                        "TASK: CLASSIFY_WEB_BUILD\n"
                        "Analyze if the user is asking to build a website, landing page, web page, or web UI.\n"
                        "Output EXACTLY a valid JSON object with a single boolean key 'is_web_build'.\n"
                        f"USER REQUEST: {prompt_text}"
                    )
                    try:
                        # Removed invalid current_task kwarg
                        gate_resp = api.chat(prompt=gate_prompt, context={}, model="openai/gpt-oss-120b", provider="openrouter", repo_map="")
                        if "true" in gate_resp.lower() and "is_web_build" in gate_resp:
                            return "frontend-web"
                    except Exception:
                        pass

                # 3. AI Classifier for non-data skills (Fallback)
                classifier_prompt = (
                    "TASK: CLASSIFY_INTENT\n"
                    f"Classify this request into one of these available skills: {available_skills}.\n"
                    "If no clear match, return 'NONE'. Output ONLY the word.\n\n"
                    f"REQUEST: {prompt_text}"
                )
                try:
                    # Removed invalid current_task kwarg
                    response = api.chat(prompt=classifier_prompt, context={}, model=config.DEFAULT_MODEL, provider="openrouter", repo_map="")
                    match = response.strip()
                    return match if match in available_skills else None
                except Exception:
                    return None

    def _execute_skill_flow(self, skill_name: str, prompt_text: str, override_model: str = None):
                """Executes the strict skill flow using the localized component data."""
                
                # Delegate to dedicated orchestrator for frontend-web
                if skill_name == "frontend-web":
                    from rich.panel import Panel
                    self.interface.print()
                    self.interface.print(Panel(
                        "[bold white]Your NOVA Web Developer is here.[/bold white]\n[dim]Analyzing requirements and preparing your website blueprint...[/dim]",
                        title="[bold magenta]NOVA The Builder ACTIVE[/bold magenta]",
                        border_style="magenta",
                        expand=False
                        ))
                        
                    import importlib.util
                    project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                    builder_path = os.path.join(project_root, "skills", "frontend-web", "builder.py")
                    
                    # Fallback path if installed as editable package
                    if not os.path.exists(builder_path):
                        builder_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "skills", "frontend-web", "builder.py")

                    if os.path.exists(builder_path):
                        spec = importlib.util.spec_from_file_location("web_builder", builder_path)
                        web_builder = importlib.util.module_from_spec(spec)
                        sys.modules["web_builder"] = web_builder
                        spec.loader.exec_module(web_builder)
                        
                        # Pre-build Context generation
                        self._step_start("Preparing project context...")
                        from nova_cli.local.contextifier import run_contextify
                        run_contextify(os.getcwd(), save_to_disk=True)
                        self._step_ok("Project context ready.")

                        modified_files = web_builder.run(user_prompt=prompt_text)
                        
                        # Contextify and Auto-Run (Mirroring standard build flow)
                        if modified_files:
                            self._step_start("Web build applied. Refreshing project context...")
                            from nova_cli.local.contextifier import run_contextify
                            new_context = run_contextify(os.getcwd(), save_to_disk=True)
                            self.state.loaded_files.update(new_context)
                            for rel_path in new_context.keys():
                                abs_p = os.path.abspath(rel_path).replace("\\", "/")
                                self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
                            self._step_ok("Project context updated.")
                            
                            # Determine entry point to run
                            entry_point = None
                            for f in modified_files:
                                if isinstance(f, str) and f.endswith("index.html"):
                                    entry_point = f
                                    break
                            if not entry_point and modified_files:
                                entry_point = modified_files[0]
                                
                            if isinstance(entry_point, str) and not entry_point.startswith("DELETED:"):
                                self.interface.print(f"\n[bold green]>> Automatically running {os.path.basename(entry_point)}...[/bold green]")
                                self.cmd_run(entry_point)

                    else:
                        self.interface.print("[red]>> Skill execution failed: builder.py not found in frontend-web.[/red]")
                    return

                import subprocess
                import questionary
                self.interface.print(f"[cyan]>> Initiating Skill Protocol: {skill_name}[/cyan]")
                
                # Resolve Absolute Path to Skills Folder
                project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
                skill_dir = os.path.join(project_root, "skills", skill_name)
                
                # Site-packages / Package-internal fallback
                if not os.path.exists(skill_dir):
                    skill_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "skills", skill_name)

                target_md_filename = "SKILL.md"
                analysis_mode = "simple"
                
                # NEW: Data Analysis Interactive Menu
                if skill_name == "data-analysis":
                    import questionary
                    choice_main = questionary.select(
                        "How would you like to analyze this data?",
                        choices=[
                            "1. Simple Analysis with interpretation", 
                            "2. Technical Dashboard (EDA)",
                            "3. Strategic BI Dashboard (Executive View)"
                        ]
                    ).ask()
                    
                    if choice_main and "2. Technical Dashboard" in choice_main:
                        choice_dash = questionary.select(
                            "Which dashboard technology should NOVA use?",
                            choices=["1. Streamlit", "2. Browser based (Html, css, js)"]
                        ).ask()
                        
                        if choice_dash and "1. Streamlit" in choice_dash:
                            target_md_filename = "streamlit_da_dashaboard.md"
                            analysis_mode = "streamlit"
                        elif choice_dash and "2. Browser based" in choice_dash:
                            target_md_filename = "dashboard.md"
                            analysis_mode = "browser"
                    
                    elif choice_main and "3. Strategic BI Dashboard" in choice_main:
                        target_md_filename = "BA_skill.md"
                        analysis_mode = "bi"

                    # Determine dynamic subtext
                    if analysis_mode == "streamlit":
                        analyst_subtext = "Profiling dataset(s) and preparing your Streamlit executive dashboard..."
                    elif analysis_mode == "browser":
                        analyst_subtext = "Profiling dataset(s) and preparing your Browser-based executive dashboard..."
                    elif analysis_mode == "bi":
                        analyst_subtext = "Invoking Kimi BI Orchestrator: Analyzing data patterns and architecting strategic insights..."
                    else:
                        analyst_subtext = "Profiling dataset(s) and preparing your comprehensive analysis..."

                    from rich.panel import Panel
                    self.interface.print()
                    self.interface.print(Panel(
                        f"[bold white]Your NOVA Data Analyst is here.[/bold white]\n[dim]{analyst_subtext}[/dim]",
                        title="[bold cyan]NOVA Data Analyst ACTIVE[/bold cyan]",
                        border_style="cyan",
                        expand=False
                    ))

                skill_md_path = os.path.abspath(os.path.join(skill_dir, target_md_filename))
                pipeline_path = os.path.abspath(os.path.join(skill_dir, "pipeline.py"))
                
                if not os.path.exists(skill_md_path):
                    self.interface.print(f"[red]>> Skill execution failed: {skill_md_path} not found.[/red]")
                    return
                    
                self._step_start(f"Loading {skill_name} component context...")
                skill_context = {}
                with open(skill_md_path, "r", encoding="utf-8") as f:
                    skill_context[target_md_filename] = f.read()
                    
                # Execute pipeline.py to extract JSON data
                if os.path.exists(pipeline_path):
                    self._step_start(f"Executing {skill_name} pipeline preprocessing...")
                    
                    # 1. Extract and Validate Target File(s)
                    import re
                    file_matches = re.findall(r'[\w\.\-/]+\.(?:csv|xlsx|json|parquet|xls)', prompt_text)
                    target_files = []
                    
                    # Check if any matches exist physically on disk
                    for match in file_matches:
                        clean_match = match.strip("'\"")
                        if os.path.exists(clean_match) and clean_match not in target_files:
                            target_files.append(clean_match)
                    
                    if not target_files:
                        self._step_fail("Data Analysis Aborted.")
                        msg = f"NOVA couldn't find the data file '{file_matches[0]}' in your current folder." if file_matches else "No supported data file (.csv, .xlsx, etc.) was detected in your request."
                        self.interface.display_error(msg, title="File Not Found")
                        return

                    # 2. Execute Data Pipeline for all files
                    files_str = ", ".join(target_files)
                    self._step_start(f"Profiling dataset(s): {files_str}")
                    pipeline_args = [sys.executable, pipeline_path] + target_files

                    try:
                        result = subprocess.run(
                            pipeline_args,
                            cwd=os.getcwd(),
                            capture_output=True,
                            text=True
                        )
                        if result.returncode == 0:
                            skill_context["pipeline_output.json"] = result.stdout
                            self._step_ok(f"Dataset(s) '{files_str}' profiled successfully.")
                        else:
                            self._step_fail(f"Pipeline execution failed for '{files_str}'.")
                            self.interface.print(f"[red]Error:[/red] {result.stderr or result.stdout}")
                            return # Hard stop if profiling fails
                    except Exception as e:
                        self._step_fail(f"Critical error running pipeline: {e}")
                        return

                # --- NULL QUALITY GATE ---
                # Fires for all analysis modes. Detects columns with >=80% null values,
                # shows them to the user, and gives an option to clean or continue.
                try:
                    import json as _json
                    _pipeline_json = _json.loads(skill_context.get("pipeline_output.json", "{}"))
                    _high_null_cols = [
                        col for col in _pipeline_json.get("columns", [])
                        if col.get("null_pct", 0) >= 80
                    ]
                    if _high_null_cols:
                        from rich.table import Table as _RichTable
                        _null_table = _RichTable(
                            title="[bold yellow]⚠  High-Null Columns Detected (≥80% missing)[/bold yellow]",
                            border_style="yellow",
                            show_lines=True
                        )
                        _null_table.add_column("Column", style="bold white", min_width=20)
                        _null_table.add_column("Null %", style="bold red", justify="right")
                        _null_table.add_column("Type", style="dim")
                        for _col in _high_null_cols:
                            _null_table.add_row(
                                _col["name"],
                                f"{_col['null_pct']}%",
                                _col.get("col_type", "unknown")
                            )
                        self.interface.print()
                        self.interface.print(_null_table)
                        self.interface.print()

                        _clean_choice = questionary.select(
                            f"{len(_high_null_cols)} column(s) carry ≥80% null values and will distort analysis. How would you like to proceed?",
                            choices=[
                                "Drop these columns and use a cleaned file",
                                "Continue with raw data (keep all columns)"
                            ]
                        ).ask()

                        if _clean_choice and "Drop" in _clean_choice:
                            self._step_start("Cleaning dataset: dropping high-null columns...")
                            import tempfile as _tempfile
                            _cols_to_drop = [c["name"] for c in _high_null_cols]
                            _src_file = target_files[0]
                            _cleaned_filename = f"cleaned_{os.path.splitext(os.path.basename(_src_file))[0]}.csv"

                            # Write a self-contained cleaning script to a temp file to avoid
                            # shell quoting issues with paths or column names containing spaces
                            _clean_lines = [
                                "import pandas as pd, os, sys",
                                f"src = {repr(_src_file)}",
                                f"cols_to_drop = {repr(_cols_to_drop)}",
                                "ext = os.path.splitext(src)[1].lower()",
                                "if ext == '.csv':",
                                "    df = pd.read_csv(src)",
                                "elif ext in ['.xlsx', '.xls']:",
                                "    df = pd.read_excel(src)",
                                "elif ext == '.json':",
                                "    df = pd.read_json(src)",
                                "elif ext == '.parquet':",
                                "    df = pd.read_parquet(src)",
                                "else:",
                                "    print(f'Unsupported extension: {ext}', file=sys.stderr); sys.exit(1)",
                                "cols_present = [c for c in cols_to_drop if c in df.columns]",
                                "df_clean = df.drop(columns=cols_present)",
                                f"out = {repr(_cleaned_filename)}",
                                "df_clean.to_csv(out, index=False)",
                                "print(f'Saved: {out} | Dropped {len(cols_present)} column(s): {cols_present} | New shape: {df_clean.shape}')",
                            ]

                            with _tempfile.NamedTemporaryFile(
                                mode='w', suffix='_nova_clean.py',
                                delete=False, dir=os.getcwd(), encoding='utf-8'
                            ) as _tf:
                                _tf.write("\n".join(_clean_lines))
                                _temp_clean_path = _tf.name

                            try:
                                _clean_result = subprocess.run(
                                    [sys.executable, _temp_clean_path],
                                    cwd=os.getcwd(), capture_output=True, text=True
                                )
                            finally:
                                try:
                                    os.remove(_temp_clean_path)
                                except Exception:
                                    pass

                            if _clean_result.returncode == 0:
                                self._step_ok(f"Cleaned file ready: {_cleaned_filename}")
                                self.interface.print(f"[dim]{_clean_result.stdout.strip()}[/dim]")
                                # Redirect all downstream processing to the cleaned file
                                target_files = [_cleaned_filename]
                                files_str = _cleaned_filename
                                # Re-profile so domain inference and dashboard use clean stats
                                self._step_start("Re-profiling cleaned dataset...")
                                _re_result = subprocess.run(
                                    [sys.executable, pipeline_path, _cleaned_filename],
                                    cwd=os.getcwd(), capture_output=True, text=True
                                )
                                if _re_result.returncode == 0:
                                    skill_context["pipeline_output.json"] = _re_result.stdout
                                    self._step_ok("Cleaned dataset profiled successfully.")
                                else:
                                    self._step_warn("Re-profiling failed. Proceeding with original profile.")
                            else:
                                self._step_warn(
                                    f"Cleaning script failed: {_clean_result.stderr[:120].strip()}. "
                                    "Proceeding with raw data."
                                )
                except Exception as _e:
                    self._step_warn(f"Null quality gate encountered a non-fatal error: {_e}. Continuing.")

                self._step_ok(f"Context loaded for {skill_name}.")

                # --- KIMI STRATEGIC DOMAIN INFERENCE PASS ---
                if analysis_mode == "bi":
                    self._step_start("Kimi is performing Strategic Domain Inference...")
                    mapping_prompt = (
                        "SYSTEM: You are a Principal Business Consultant. Analyze this dataset profile and raw data head to determine the Business Narrative.\n"
                        "1. VERTICAL: Identify the industry (Sales, Logistics, FinTech, etc.).\n"
                        "2. NORTH STAR: What is the primary metric of success found in this data?\n"
                        "3. ENTITIES: Identify human/organizational entities for leaderboards (Agents, Managers, Teams).\n"
                        "4. FLOW: Is this a funnel, a timeline, or a volume-based dataset?\n"
                        "5. AUDIT: Identify 'Actionable Alerts' (Stagnant records, missing high-value info, etc.).\n"
                        "Output EXACTLY a JSON object with keys: vertical, north_star, entities, flow_type, audit_alerts.\n\n"
                        f"PROFILE_JSON: {skill_context.get('pipeline_output.json', '{}')}"
                    )
                    api_client = BridgeyeAPIClient()
                    # Switching to Kimi for the Inference
                    domain_map = api_client.chat(
                        prompt=mapping_prompt,
                        context={},
                        model="moonshotai/kimi-k2.6",
                        provider="openrouter"
                    )
                    skill_context["domain_mapping.json"] = domain_map
                    self._step_ok("Kimi Intelligence initialized. Strategic narrative locked.")

                # --- COLUMN_MANIFEST BUILDER (BI Mode only) ---
                # Extracts a compact, token-efficient manifest from pipeline_output.json.
                # Replaces raw JSON in the prompt so Kimi sees actual categorical values,
                # actual date ranges, and actual numeric bounds — no guessing, any domain.
                column_manifest = ""
                if analysis_mode == "bi":
                    try:
                        import json as _jm
                        _pj = _jm.loads(skill_context.get("pipeline_output.json", "{}"))
                        _manifest_lines = ["COLUMN_MANIFEST (derived from pipeline profile):"]
                        _manifest_lines.append(
                            f"Dataset: {_pj.get('shape', {}).get('rows', '?')} rows x "
                            f"{_pj.get('shape', {}).get('cols', '?')} cols"
                        )
                        _manifest_lines.append(
                            f"Duplicates: {_pj.get('duplicate_pct', 0)}%"
                        )
                        _manifest_lines.append("")
                        for _col in _pj.get("columns", []):
                            _ctype = _col.get("col_type", "unknown")
                            _null  = _col.get("null_pct", 0)
                            _name  = _col.get("name", "?")
                            # Skip columns that are effectively useless
                            if _null >= 95:
                                continue
                            _line = f"  [{_ctype}] {_name} | null={_null}%"
                            if _ctype == "categorical":
                                _tv = _col.get("top_values", {})
                                if _tv:
                                    _vals = ", ".join(
                                        f'"{k}"({v})' for k, v in list(_tv.items())[:7]
                                    )
                                    _line += f" | values: {_vals}"
                            elif _ctype == "numeric":
                                _line += (
                                    f" | min={_col.get('min')} max={_col.get('max')} "
                                    f"mean={_col.get('mean')} skew={_col.get('skewness')}"
                                )
                            elif _ctype == "datetime":
                                _line += (
                                    f" | range: {_col.get('min_date')} → {_col.get('max_date')} "
                                    f"({_col.get('range_days')} days)"
                                )
                            elif _ctype == "boolean":
                                _vc = _col.get("value_counts", {})
                                _line += f" | values: {_vc}"
                            _manifest_lines.append(_line)
                        _manifest_lines.append("")
                        _manifest_lines.append(
                            "High correlations: " +
                            str(_pj.get("high_correlations", []))
                        )
                        _manifest_lines.append(
                            "Quality flags: " +
                            "; ".join(_pj.get("flags", []))
                        )
                        column_manifest = "\n".join(_manifest_lines)
                    except Exception as _em:
                        column_manifest = f"[COLUMN_MANIFEST unavailable: {_em}]"

                # --- CUSTOM DASHBOARD REQUIREMENTS GATE (BI Mode only) ---
                # Captures any specific KPIs / charts the user wants before generation starts.
                # Empty input (Enter) means fully auto-generated.
                user_dashboard_requirements = ""
                if analysis_mode == "bi":
                    self.interface.print()
                    _custom_req = questionary.text(
                        "Any specific KPIs, charts, or metrics you want in the dashboard?\n"
                        "  e.g. 'Monthly revenue trend, agent leaderboard, conversion funnel by region'\n"
                        "  Press Enter to auto-generate based on domain inference:"
                    ).ask()
                    if _custom_req and _custom_req.strip():
                        user_dashboard_requirements = _custom_req.strip()
                        self.interface.print(
                            f"[dim]>> Custom requirements locked in: {user_dashboard_requirements}[/dim]"
                        )
                    else:
                        self.interface.print(
                            "[dim]>> No custom requirements. Auto-generating optimal CXO layout...[/dim]"
                        )
                    self.interface.print()

                # Enforce Kimi k2.6 for Skill Execution
                target_model = "moonshotai/kimi-k2.6"
                target_provider = "openrouter"
                
                # Display the Purple Upgrade Banner
                self.interface.display_coding_mode(target_model)
                
                # Build optional custom requirements block for BI mode
                _custom_req_block = (
                    f"\n\nUSER_DASHBOARD_REQUIREMENTS — HIGHEST PRIORITY. You MUST implement these "
                    f"exactly as specified in addition to all standard dashboard sections:\n{user_dashboard_requirements}"
                ) if user_dashboard_requirements else ""

                # Build data loading context block for BI mode
                _data_loading_block = ""
                if analysis_mode == "bi":
                    _file_exts = list({os.path.splitext(f)[1].lower() for f in target_files})
                    _multi_file_note = (
                        f"The user has {len(target_files)} data files with the same schema: "
                        f"{', '.join(os.path.basename(f) for f in target_files)}. "
                        "The upload zone MUST accept all of them simultaneously and concat the results."
                    ) if len(target_files) > 1 else (
                        f"The user has 1 data file: {os.path.basename(target_files[0])} "
                        f"(extension: {_file_exts[0]}). The upload zone must accept this file type."
                    )
                    _data_loading_block = (
                        f"\n\nDATA_LOADING_CONTEXT: Do NOT hardcode any data arrays or file paths. "
                        f"Implement the two-phase upload architecture exactly as defined in the skill instructions. "
                        f"{_multi_file_note} "
                        f"All KPI values, chart series, and filter options must be computed dynamically from the "
                        f"parsed DATA array at runtime. The COLUMN_MANIFEST and domain_mapping.json are provided "
                        f"solely to inform WHICH KPIs and charts to build — not as the data source for the dashboard."
                    )

                # Build column manifest block
                _manifest_block = (
                    f"\n\n{column_manifest}"
                ) if column_manifest else ""

                skill_prompt = (
                    f"SYSTEM_OVERRIDE: You are executing the '{skill_name}' skill.\n"
                    f"Strictly follow the rules, intent triggers, and instructions defined in the provided {target_md_filename} context.\n"
                    "Always use the exact [CREATE: filename.ext] syntax to output files.\n\n"
                    f"USER_REQUEST: {prompt_text}"
                    f"{_manifest_block}"
                    f"{_data_loading_block}"
                    f"{_custom_req_block}"
                )
                
                self.interface.print(f"[cyan]>> NOVA is processing the {skill_name} request...[/cyan]")
                api = BridgeyeAPIClient()
                
                # Stream the reasoning and output
                output = self.interface.stream_rich_response(
                    api.chat_stream(
                        prompt=skill_prompt,
                        context=skill_context,
                        model=target_model,
                        provider=target_provider,
                        repo_map=prompts.get_repo_map_cached(os.getcwd())
                    )
                )
                
                if not output:
                    self._step_fail("No response received from Skill Execution.")
                    return
                    
                self.state.last_ai_response = output
                
                # 3. Parse [CREATE] tags, save file, and trigger execution
                modified_files = handle_ai_commands(output)
                
                if modified_files:
                    prompts.clear_file_tree_cache()
                    
                    if analysis_mode == "streamlit":
                        # Dashboard Execution Phase (Streamlit)
                        for fpath in modified_files:
                            if isinstance(fpath, str) and fpath.endswith(".py") and not fpath.startswith("DELETED:"):
                                self.interface.print(f"[bold green]>> Auto-running Streamlit Dashboard: {os.path.basename(fpath)}...[/bold green]")
                                self.cmd_run(fpath)
                                break
                    elif analysis_mode in ["browser", "bi"]:
                        # Dashboard Execution Phase (HTML/JS)
                        for fpath in modified_files:
                            if isinstance(fpath, str) and fpath.endswith((".html", ".htm")) and not fpath.startswith("DELETED:"):
                                title = "Strategic BI Dashboard" if analysis_mode == "bi" else "Browser Dashboard"
                                self.interface.print(f"[bold green]>> Auto-running {title}: {os.path.basename(fpath)}...[/bold green]")
                                self.cmd_run(fpath)
                                break
                    else:
                        # Simple Analysis Execution Phase
                        for fpath in modified_files:
                            if isinstance(fpath, str) and fpath.endswith(".py") and not fpath.startswith("DELETED:"):
                                self.interface.print(f"[bold green]>> Auto-running generated skill script: {os.path.basename(fpath)}...[/bold green]")
                                
                                ensure_dependencies(fpath)

                                # Capture the output for interpretation
                                import subprocess
                                from nova_cli.local.healer.runner import run_with_healing
                                
                                try:
                                    # Use run_with_healing to ensure the file works, then capture final output
                                    execution_result = run_with_healing(
                                        command_args=[sys.executable, fpath],
                                        cwd=os.getcwd(),
                                        model=target_model,
                                        provider=target_provider,
                                        context=self.state.loaded_files
                                    )
                                    
                                    # --- INTERPRETATION PHASE (Groq 120b) ---
                                    if execution_result:
                                        self.interface.print("\n[bold cyan]• NOVA is interpreting the analysis results...[/bold cyan]")
                                        interpret_prompt = (
                                            "SYSTEM_OVERRIDE: You are a Lead Data Scientist.\n"
                                            f"The following is the terminal output from a data analysis script run on {', '.join(target_files)}.\n"
                                            "Interpret the numbers, trends, and quality flags. Provide a high-level executive summary.\n\n"
                                            f"TERMINAL_OUTPUT:\n{execution_result}"
                                        )
                                        
                                        self.interface.stream_rich_response(
                                            api.chat_stream(
                                                prompt=interpret_prompt,
                                                context={},
                                                model="openai/gpt-oss-120b",
                                                provider="openrouter"
                                            )
                                        )
                                except Exception as e:
                                    self.interface.print(f"[red]Execution interpretation failed: {e}[/red]")
                                break
                else:
                    self._step_warn("No files were created or modified by the AI. Check if [CREATE] tags were missing in the response.")


--- FILE: cli/shell_parts/handlers.py ---

import logging
import os
import re
import shlex
import subprocess
import sys
import time

from core import prompts
from nova_cli import config
from nova_cli.local.contextifier.engine import run_contextify
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.file_manager.git_ops import git_status, manual_commit, perform_pull, perform_push, update_repo_path
from nova_cli.local.file_manager.io_ops import load_file, map_directory, save_code_to_file
from nova_cli.local.file_manager.path_ops import resolve_path
from nova_cli.local.healer.runner import run_with_healing
from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli.nova_core.auth.client import NovaAuthClient
from nova_cli.nova_core.auth.storage import save_auth



class ShellHandlersMixin:
    def cmd_overdrive(self, args):
            # Handle both ":overdrive" (toggle) and ":exit overdrive" (off)
            if "overdrive" in args.lower() and "exit" in sys._getframe(1).f_locals.get('cmd_raw', ''):
                config.OVERDRIVE = False
            else:
                config.OVERDRIVE = not config.OVERDRIVE
                
            status = "[bold green]ENABLED[/bold green]" if config.OVERDRIVE else "[bold red]DISABLED[/bold red]"
            self.interface.print(f"[cyan]>> Overdrive Mode: {status}[/cyan]")

    def cmd_makeroot(self, args):
            config.PROJECT_ROOT = os.path.abspath(os.getcwd())
            self.interface.print(f"[bold yellow]>> Restricted Root Set:[/bold yellow] {config.PROJECT_ROOT}")
            self.interface.print("[dim]NOVA will no longer create or edit files outside this directory.[/dim]")

    def cmd_exitroot(self, args):
            config.PROJECT_ROOT = config.INITIAL_ROOT
            self.interface.print(f"[bold green]>> Root Restored to Initial Path:[/bold green] {config.PROJECT_ROOT}")

    def cmd_doctor(self, args):
            from nova_cli.cli.main import _doctor
            _doctor()

    def cmd_help(self, args):
            help_data = {
                "Build & Execute": {
                    "build / build it": "Execute the implementation steps in PLAN.md",
                    "continue": "Resume an interrupted build process",
                    "run <cmd>": "Run code with automated error healing",
                    "clean": "Trigger Janitor for style & formatting refactor"
                },
                "File & Context": {
                    "ls / :map": "View project blueprint (AST map)",
                    ":create [folder|file] <name>": "Create a folder or file instantly",
                    ":delete [folder|file] <name>": "Delete a folder or file safely",
                    ":load <path>": "Load file content into AI memory",
                    ":unload": "Clear specific or all files from context",
                    ":paste": "Provide multi-line logs or snippets",
                    "cd / pwd": "Navigate directories / Print current path"
                },
                "System & Auth": {
                    "login": "Authenticate your NOVA session",
                    ":model": "Switch AI reasoning/coding models",
                    ":gitoptions": "Git Automation (Commit/Push/Pull)",
                    ":overdrive": "Enable auto-confirm (Prompt turns RED)",
                    ":exit overdrive": "Disable auto-confirm mode",
                    ":makeroot": "Lock NOVA operations to current folder",
                    ":exitroot": "Release folder lock to initial root",
                    "reset / exit": "Clear session / Shutdown NOVA"
                }
            }
            self.interface.render_help_menu(help_data)

    def cmd_gitoptions(self, args):
            from nova_cli.local.file_manager.git_ops import initialize_repo
            
            while True:
                # Pass current state to UI for display
                choice = self.interface.show_git_options(config.GIT_AUTO_COMMIT, config.GIT_AUTO_PUSH)

                if choice == "Back":
                    break
                elif choice == "Initialize":
                    initialize_repo()
                elif choice == "Toggle Auto-Commit":
                    config.GIT_AUTO_COMMIT = not config.GIT_AUTO_COMMIT
                    status = "ENABLED" if config.GIT_AUTO_COMMIT else "DISABLED"
                    self.interface.print(f"[yellow]>> Auto-Commit: {status}[/yellow]")
                elif choice == "Toggle Auto-Push":
                    config.GIT_AUTO_PUSH = not config.GIT_AUTO_PUSH
                    status = "ENABLED" if config.GIT_AUTO_PUSH else "DISABLED"
                    self.interface.print(f"[yellow]>> Auto-Push: {status}[/yellow]")
                elif choice == "Manual Commit":
                    manual_commit(model=self.model_name, provider=self.provider)
                elif choice == "Push":
                    perform_push(model=self.model_name, provider=self.provider)
                elif choice == "Pull":
                    perform_pull()
                elif choice == "Force Sync":
                    from nova_cli.local.file_manager.git_ops import force_sync_with_origin
                    force_sync_with_origin()
                elif choice == "Git Status":
                    git_status()
                    self.interface.input("[dim]Press Enter to continue...[/dim]")

    def cmd_login(self, args):
            auth = NovaAuthClient()

            print("[cyan]Opening browser for authentication...[/cyan]")
            session_id = auth.create_session()
            auth.open_browser(session_id)

            print("[dim]Waiting for approval...[/dim]")

            auth_code = auth.poll_session(session_id)

            if not auth_code:
                print("[red]Login timed out.[/red]")
                return

            tokens = auth.exchange_auth_code(auth_code)

            if not tokens or tokens.get("error"):
                self.interface.print("\n[bold red]Verification failed.[/bold red]")
                self.interface.print("[cyan]Please login again. Give us two seconds while we verify you.[/cyan]")
                return


            tokens["issued_at"] = time.time()
            save_auth(tokens)

            self.interface.print("[bold green]Login successful! You are now authenticated.[/bold green]")
            self.interface.display_startup_hint()

    def cmd_model(self, args):
            new_model, new_provider = self.interface.show_model_selector(self.model_name)
            if new_model != self.model_name:
                self.model_name = new_model
                self.provider = new_provider
                logging.info(f"Switched model to {self.model_name}")
                self.interface.display_header(self.model_name, os.getcwd())

    def cmd_reset(self, args):
            self.state.reset()
            self.interface.clear()
            logging.info("Session reset")
            self.interface.display_header(self.model_name, os.getcwd())

    def cmd_unload(self, args):
            if not args:
                self.state.active_file = None
                self.state.loaded_files = {}
                self.state.loaded_paths = {}
                self.interface.print("[dim]  Unloaded all files.[/dim]")
            else:
                fname = args.replace('"', "").replace("'", "").strip()
                found_key = None
                for key in self.state.loaded_files.keys():
                    if key == fname or os.path.basename(key) == fname:
                        found_key = key
                        break

                if found_key:
                    del self.state.loaded_files[found_key]
                    if found_key in self.state.loaded_paths:
                        del self.state.loaded_paths[found_key]

                    self.interface.print(f"[dim]  Unloaded: {found_key}[/dim]")

                    if self.state.active_file and os.path.basename(self.state.active_file) == found_key:
                        self.state.active_file = None
                else:
                    self.interface.print(f"[red]  File '{fname}' is not currently loaded.[/red]")


    def cmd_map(self, args):
            """Triggers a recursive AST scan and displays the Project Blueprint."""
            prompts.clear_file_tree_cache()
            # 1. Visual Folder Tree
            map_directory()
            # 2. AST Blueprint (Project Map)
            self._step_start("Scanning project architecture...")
            blueprint = prompts.get_repo_map_cached(os.getcwd())
            self.interface.display_blueprint(blueprint)

    def cmd_wizard(self, args):
            self.interface.print("[yellow]Wizard is not available in API-only CLI mode yet.[/yellow]")

    def cmd_apply(self, args):
            save_code_to_file(self.state.active_file, self.state.last_generated_code)

    def cmd_cd(self, args):
            if not args:
                return
            try:
                target_path = os.path.abspath(args)
                
                # Security Barrier: check against global PROJECT_ROOT using commonpath
                if os.path.commonpath([config.PROJECT_ROOT, target_path]) != config.PROJECT_ROOT:
                    self.interface.print("[bold red]SECURITY ALERT:[/bold red] You are in sticky root. To disable it, use :exitroot")
                    logging.warning(f"Access denied: {target_path}")
                    return

                os.chdir(target_path)
                self.interface.print(f"[dim]  cwd: {os.getcwd()}[/dim]")
                if os.path.exists(os.path.join(os.getcwd(), ".git")):
                    update_repo_path(os.getcwd())
            except Exception as e:
                self.interface.print(f"[red]{e}[/red]")

    def cmd_pwd(self, args):
            self.interface.print(f"[dim]{os.getcwd()}[/dim]")

    def cmd_build_it(self, args):
            plan_path = os.path.join(os.getcwd(), "PLAN.md")
            if not os.path.exists(plan_path):
                if not args:
                    # No args + no PLAN.md: prompt inline rather than dead-end
                    self.interface.print("[yellow]No PLAN.md found.[/yellow]")
                    description = self.interface.input("[cyan]What do you want to build? > [/cyan]").strip()
                    if not description:
                        self.interface.print("[dim]Cancelled.[/dim]")
                        return
                    self.interface.print("[cyan]>> Rerouting to Architect & Planning Phase...[/cyan]")
                    # "Create ..." guarantees PLAN classification; triggers enhancer → PLAN.md
                    return f"Create {description}"

                self.interface.print("[cyan]>> No PLAN.md found. Rerouting to Architect & Planning Phase...[/cyan]")
                # Returning a string delegates to the AI loop.
                # Prefix with "Create" to guarantee PLAN classification.
                return f"Create {args}"

            try:
                with open(plan_path, "r", encoding="utf-8") as f:
                    plan_content = f.read()
            except Exception as e:
                self.interface.print(f"[red]Failed to read PLAN.md: {e}[/red]")
                return

            self._step_start("Contextifying project and regenerating project_context.txt...")
            if os.path.exists("project_context.txt"):
                try:
                    os.remove("project_context.txt")
                except Exception:
                    pass
            
            self.state.loaded_files.clear()
            self.state.loaded_paths.clear()
            
            project_context = run_contextify(os.getcwd(), save_to_disk=True)
            self.state.loaded_files.update(project_context)
            for rel_path in project_context.keys():
                abs_p = os.path.abspath(rel_path).replace("\\", "/")
                self.state.loaded_paths[os.path.basename(rel_path)] = abs_p
            
            self._step_ok("Context updated. project_context.txt regenerated.")

            api = BridgeyeAPIClient()

            self._step_start("Validating PLAN.md")

            try:
                with self.interface.create_loader("Validating implementation plan..."):
                    validation = api.validate_plan(
                        plan_content=plan_content,
                        model=self.model_name,
                        provider=self.provider,
                    )
            except Exception as e:
                self._step_fail("PLAN.md validation failed")
                self.interface.display_error(str(e), title="Validation Failed")
                return

            is_valid = bool((validation or {}).get("is_valid"))
            improved_plan = ((validation or {}).get("improved_plan") or "").strip()

            if is_valid:
                self._step_ok("PLAN.md is build-ready")
            else:
                if improved_plan:
                    self._step_warn("PLAN.md was incomplete, improving it before build")
                    try:
                        with open(plan_path, "w", encoding="utf-8") as f:
                            f.write(improved_plan)
                        plan_content = improved_plan
                        self._step_ok("PLAN.md improved and saved")
                    except Exception as e:
                        self._step_fail("Failed to save improved PLAN.md")
                        self.interface.print(f"[bold red]Failed to update PLAN.md:[/bold red] {e}")
                        return
                else:
                    self._step_fail("PLAN.md is not build-ready")
                    self.interface.print("[bold red]Plan validation failed: PLAN.md is not build-ready and no improved plan was returned.[/bold red]")
                    return

            # Model Selection logic: Skip UI if only one model is available
            import questionary

            build_models = [
                questionary.Choice(title="Kimi k2.6 - Deep Reasoning (64k Context)", value="moonshotai/kimi-k2.6"),
            ]

            if len(build_models) == 1:
                model_choice = build_models[0].value
            else:
                model_choice = questionary.select(
                    "Who should implement this plan?",
                    choices=build_models
                ).ask()

            if not model_choice:
                return

            self._step_start(f"Starting implementation pass with {model_choice}")
            start_time = time.time()

            prompt = (
                "SYSTEM_OVERRIDE: DISREGARD PREVIOUS PLANNING INSTRUCTIONS.\n"
                "PHASE: IMPLEMENTATION.\n"
                "ACT AS: Senior Lead Developer.\n"
                "TASK: Read the provided PLAN.md and generate the FULL implementation for ALL files.\n\n"
                "STRICT OUTPUT CONTRACT:\n"
                "1. You may emit short progress markers first, each on its own line, using ONLY:\n"
                "   [STATUS] <what you are doing now>\n"
                "   [FILE] <file path>\n"
                "2. After progress markers, output FINAL FILES ONLY.\n"
                "3. Every file MUST follow this exact format with no extra text in between:\n"
                "   [CREATE: path/to/file]\n"
                "   ```python\n"
                "   <full file contents>\n"
                "   ```\n"
                "4. Do not put explanations before, inside, or after [CREATE] blocks.\n"
                "5. Do not use bullets, numbering, commentary, or markdown headings in the final file section.\n"
                "6. Do not stop after progress markers. You must output all final [CREATE: ...] blocks.\n"
                "7. Ensure every [CREATE: ...] tag is immediately followed by a fenced code block.\n"
                "8. If only one file is needed, still use the exact [CREATE: filename] + fenced code block format.\n\n"
                f"PLAN.md CONTENT:\n{plan_content}"
            )

            self.handle_ai_request_streaming_build(
                prompt_text=prompt,
                plan_content=plan_content,
                override_model=model_choice
            )
            
            duration = time.time() - start_time
            self.interface.print(f"\n[dim]>> Build time ({model_choice.split('/')[-1]}): {duration:.2f}s[/dim]")

    def cmd_exit(self, args):
            logging.info("Shutdown")
            return "EXIT"

    def cmd_load(self, args):
            """
            :load should ONLY load file contents into context.
            It must NOT trigger any AI call.
            """
            try:
                path_arg = (args or "").strip().replace('"', "").replace("'", "")
                if not path_arg:
                    self.interface.print("[yellow]Usage: :load <file>[/yellow]")
                    return

                f_path, content = load_file(path_arg)
                if not f_path:
                    return

                abs_path = os.path.abspath(f_path).replace("\\", "/")
                base = os.path.basename(f_path)

                # mark active (keep absolute path)
                self.state.active_file = abs_path

                # canonical storage: basename only
                self.state.loaded_files[base] = content
                self.state.loaded_paths[base] = abs_path

                self.interface.print(f"[green]>> Loaded into context:[/green] {base}")
                return  # IMPORTANT: do not return a string (prevents AI call)

            except Exception as e:
                self.interface.print(f"[red]Load failed: {e}[/red]")
                return



    def cmd_paste(self, args):
            if not self.state.active_file:
                self.interface.print("[red]Load a file first.[/red]")
                return
            error_log = self.interface.get_multiline_input()
            if error_log:
                return f"DEBUG_REQUEST: Fix {self.state.active_file}\nERROR:\n{error_log}"

    def cmd_run(self, args):
            if not args:
                self.interface.print("[yellow]Usage: run <filename> or run <command>[/yellow]")
                return

            target_file = args.strip().replace('"', "").replace("'", "")
            
            # 1. HTML Handling
            if target_file.lower().endswith((".html", ".htm")):
                import webbrowser
                fpath = os.path.abspath(target_file)
                if os.path.exists(fpath):
                    self.interface.print(f"[green]>> Opening {os.path.basename(fpath)} in browser...[/green]")
                    webbrowser.open(f"file://{fpath}")
                    return
                else:
                    self.interface.print(f"[red]>> File not found: {target_file}[/red]")
                    return

            command_list = shlex.split(args)
            resolved = resolve_path(command_list[0]) if command_list else None

            # 2. Unified Multi-Language Runner & Tool-Chain Logic
            import shutil
            from nova_cli.local.utils import get_platform_install_cmd, refresh_environment_variables
            
            def ensure_tool(name):
                """Unified Cross-Platform tool checker and auto-installer."""
                # 1. Check if tool is in current PATH
                path = shutil.which(name)
                if not path and sys.platform == "win32":
                    path = shutil.which(f"{name}.cmd") or shutil.which(f"{name}.exe")
                
                # 1.1 Aggressive Windows Recovery (Manual search in standard dirs)
                if not path and sys.platform == "win32":
                    if name == "Rscript":
                        import glob
                        r_paths = glob.glob(r"C:\Program Files\R\R-*\bin\x64\Rscript.exe")
                        if r_paths: path = r_paths[0]
                    elif name == "node":
                        if os.path.exists(r"C:\Program Files\nodejs\node.exe"): path = r"C:\Program Files\nodejs\node.exe"

                if path: return path

                # 2. Get OS-specific command
                install_cmd = get_platform_install_cmd(name)
                if not install_cmd:
                    self.interface.print(f"[red]Fatal: '{name}' is required but no auto-installer exists for this OS.[/red]")
                    return None

                # 3. Installation Phase
                self.interface.print(f"[bold cyan]>> Environment Sync: '{name}' is required but missing.[/bold cyan]")
                self.interface.print(f"[dim]Auto-installing via system package manager...[/dim]")
                
                try:
                    # Execute installer
                    process = subprocess.run(install_cmd, shell=True, capture_output=True, text=True)
                    
                    # Success criteria: Exit 0 OR specific "Already Installed" codes
                    win_already_installed = sys.platform == "win32" and str(process.returncode) == "2316632107"
                    
                    if process.returncode == 0 or win_already_installed:
                        self.interface.print(f"[green]>> '{name}' installation command completed.[/green]")
                        
                        # 4. Re-sync Environment (Cross-Platform PATH reload)
                        refresh_environment_variables()
                        # Return the name immediately to allow execution to proceed
                        return name
                    else:
                        self.interface.print(f"[red]Installation returned error {process.returncode}:[/red] {process.stderr or process.stdout}")
                        
                except Exception as e:
                    self.interface.print(f"[red]Installation process crashed: {e}[/red]")
                
                return None

            if len(command_list) >= 1:
                first_cmd = command_list[0]
                # If it's a file, resolve it and determine runner
                if os.path.exists(resolved or first_cmd):
                    target = resolved or first_cmd
                    ext = os.path.splitext(target)[1].lower()
                    
                    # 1. Resolve Tool First
                    tool = None
                    if ext == ".py":
                        tool = sys.executable
                    elif ext == ".js":
                        tool = ensure_tool("node")
                    elif ext in [".r", ".R"]:
                        tool = ensure_tool("Rscript")
                    
                    if not tool and ext in [".py", ".js", ".r", ".R"]:
                        return

                    # 2. Run dependency batch installation using the resolved tool path
                    from nova_cli.local.utils import ensure_dependencies
                    ensure_dependencies(target, tool_path=tool)

                    # 3. Finalize Command List
                    if ext == ".py":
                        with open(target, "r", encoding="utf-8") as f:
                            content = f.read()
                            if re.search(r"import\s+streamlit", content):
                                tool = ensure_tool("streamlit")
                                if not tool: return
                                self.interface.print(f"[bold magenta]>> Launching Streamlit Portal...[/bold magenta]")
                                
                                # 1. Format command (Windows Popen with shell=True works best with a string)
                                if sys.platform == "win32":
                                    cmd = f'"{tool}" run "{target}" --server.headless true'
                                else:
                                    cmd = [tool, "run", target, "--server.headless", "true"]

                                # 2. Launch as detached background process
                                subprocess.Popen(
                                    cmd,
                                    shell=(sys.platform == "win32"),
                                    stdout=subprocess.DEVNULL,
                                    stderr=subprocess.DEVNULL,
                                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                    start_new_session=True if sys.platform != "win32" else False
                                )
                                
                                # 3. Explicitly trigger browser open
                                import webbrowser
                                from time import sleep
                                
                                # Give the server 3 seconds to initialize before opening the page
                                sleep(3)
                                webbrowser.open("http://localhost:8501")
                                
                                self.interface.print(f"[green]✔ Streamlit server initiated. Opening http://localhost:8501 in browser...[/green]")
                                return
                            elif any(k in content for k in ["Flask", "flask", "django", "FastAPI", "fastapi", "app.run"]):
                                self.interface.print(f"[bold magenta]>> Launching Python Web Server...[/bold magenta]")
                                
                                # Detect port, default to 5000 for Flask or 8000 for others
                                port = "5000" if "flask" in content.lower() else "8000"
                                port_match = re.search(r'port\s*=\s*(\d+)', content)
                                if port_match: port = port_match.group(1)

                                if sys.platform == "win32":
                                    cmd = f'"{sys.executable}" "{target}"'
                                else:
                                    cmd = [sys.executable, target]

                                subprocess.Popen(
                                    cmd,
                                    shell=(sys.platform == "win32"),
                                    stdout=subprocess.DEVNULL,
                                    stderr=subprocess.DEVNULL,
                                    creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                    start_new_session=True if sys.platform != "win32" else False
                                )
                                
                                import webbrowser
                                time.sleep(3)
                                webbrowser.open(f"http://localhost:{port}")
                                self.interface.print(f"[green]✔ Web server initiated. Opening http://localhost:{port} in browser...[/green]")
                                return
                            else:
                                command_list = [sys.executable, target]
                    
                    elif ext == ".js" or ext == ".mjs":
                        tool = ensure_tool("node")
                        if not tool: return
                        # Check for package.json to ensure dependencies
                        if os.path.exists("package.json"):
                            self.interface.print("[dim]>> Node Project detected. Ensuring npm modules...[/dim]")
                            subprocess.call("npm install", shell=True)
                        
                        # Detect if this is an Express/Web server
                        is_web_server = False
                        try:
                            with open(target, "r", encoding="utf-8") as f:
                                js_content = f.read()
                                # Common patterns for Node.js web servers
                                if any(k in js_content for k in ["express", "http.createServer", "app.listen", ".listen("]):
                                    is_web_server = True
                        except Exception:
                            pass

                        if is_web_server:
                            self.interface.print(f"[bold magenta]>> Launching Node.js Web Server...[/bold magenta]")
                            
                            # Detect port, default to 3000 if not found
                            port_match = re.search(r'(?:port|PORT|Port)\s*[:=]\s*(\d+)', js_content)
                            port = port_match.group(1) if port_match else "3000"
                            
                            if sys.platform == "win32":
                                cmd = f'"{tool}" "{target}"'
                            else:
                                cmd = [tool, target]

                            # Launch as a background process
                            subprocess.Popen(
                                cmd,
                                shell=(sys.platform == "win32"),
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL,
                                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
                                start_new_session=True if sys.platform != "win32" else False
                            )
                            
                            import webbrowser
                            # Give the server a few seconds to boot
                            time.sleep(3) 
                            webbrowser.open(f"http://localhost:{port}")
                            self.interface.print(f"[green]✔ Web server initiated. Opening http://localhost:{port} in browser...[/green]")
                            return

                        command_list = [tool, target]
                    
                    elif ext in [".r", ".R"]:
                        tool = ensure_tool("Rscript")
                        if not tool: return
                        command_list = [tool, target]
                    
                    elif ext == ".ts":
                        ensure_tool("npm", "echo 'npm required.'")
                        tool = ensure_tool("ts-node", "npm install -g ts-node")
                        command_list = [tool, target]

                # If it's a direct command call (e.g., run npm install)
                elif first_cmd in ["npm", "npx", "Rscript", "streamlit"]:
                    tool = ensure_tool(first_cmd)
                    if tool: command_list[0] = tool
            
            # 3. Final Command Validation
            if not command_list:
                self.interface.print("[red]>> Error: Could not determine runner for this file.[/red]")
                return

            # 4. Execute with Healing
            try:
                output = run_with_healing(
                    command_args=command_list,
                    cwd=os.getcwd(),
                    model=self.model_name,
                    provider=self.provider,
                    context=self.state.loaded_files,
                    repo_map=None,
                )

                # 5. Output Interpretation Phase
                # If the file executed is an analysis script, interpret results with Groq 120b
                is_analysis = any(word in str(command_list).lower() for word in ["analyze", "analysis", "skill", "plot", "report"])
                
                if output and output != "SUCCESS_SIGNAL":
                    # 1. Always display standard output in a pretty panel
                    self.interface.script_output(output, title=f"Run Output: {os.path.basename(command_list[-1])}")

                    # 2. Advanced Analysis Detection Heuristic
                    analysis_keywords = [
                        "mean", "std", "median", "r-squared", "regression", "coefficient", 
                        "p-value", "correlation", "intercept", "variance", "summary", "count"
                    ]
                    # Detection: keywords + checking for common data patterns like numeric tables
                    is_actual_analysis = any(k in output.lower() for k in analysis_keywords) or \
                                        (re.search(r'\d+\.\d+', output) and len(output.splitlines()) > 5)

                    if is_actual_analysis:
                        import json
                        analysis_payload = json.dumps({
                            "filename": os.path.basename(command_list[-1]),
                            "terminal_raw": output,
                            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
                        })

                        interpret_prompt = (
                            "SYSTEM_OVERRIDE: You are a Senior Data Scientist.\n"
                            "Analyze the following JSON-wrapped terminal output. "
                            "Summarize key statistical findings, identify anomalies, and suggest the next logical step.\n\n"
                            f"DATA_JSON: {analysis_payload}"
                        )

                        api = BridgeyeAPIClient()
                        with self.interface.create_loader("NOVA is interpreting data..."):
                            ai_response = api.chat(
                                prompt=interpret_prompt,
                                context={},
                                model="openai/gpt-oss-120b",
                                provider="openrouter"
                            )
                        
                        if ai_response:
                            self.interface.display_interpretation(ai_response)

            except Exception as e:
                self.interface.print(f"[bold red]Healer Error:[/bold red] {e}")

    def cmd_create(self, args):
            if not args:
                self.interface.print("[yellow]Usage: :create [folder|file] <name>[/yellow]")
                return
            
            parts = args.split(maxsplit=1)
            is_dir = False
            
            if len(parts) == 2 and parts[0].lower() in ["folder", "dir", "directory"]:
                is_dir = True
                target = parts[1].strip()
            elif len(parts) == 2 and parts[0].lower() == "file":
                target = parts[1].strip()
            else:
                target = args.strip()
                if target.endswith("/") or target.endswith("\\") or ("." not in os.path.basename(target) and not target.startswith(".")):
                    is_dir = True

            target = target.replace('"', "").replace("'", "")
            target_path = os.path.abspath(target)
            
            try:
                if os.path.exists(target_path):
                    item_type = "Folder" if is_dir else "File"
                    self.interface.print(f"[bold red]>> {item_type} '{target}' already exists. Do you want to change the folder name?[/bold red]")
                    return

                if is_dir:
                    os.makedirs(target_path, exist_ok=True)
                    self.interface.print(f"[green]>> Created Directory: {target}[/green]")
                else:
                    os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
                    with open(target_path, "a", encoding="utf-8"): pass
                    self.interface.print(f"[green]>> Created File: {target}[/green]")
                
                from nova_cli.local.contextifier import run_contextify
                run_contextify(os.getcwd(), save_to_disk=True)
                prompts.clear_file_tree_cache()
            except Exception as e:
                self.interface.print(f"[red]>> Create Failed: {e}[/red]")

    def cmd_delete(self, args):
            if not args:
                self.interface.print("[yellow]Usage: :delete [folder|file] <name>[/yellow]")
                return
                
            parts = args.split(maxsplit=1)
            if len(parts) == 2 and parts[0].lower() in ["folder", "dir", "directory", "file"]:
                target = parts[1].strip()
            else:
                target = args.strip()
                
            target = target.replace('"', "").replace("'", "")
            from nova_cli.local.file_manager.io_ops import safe_delete
            
            if safe_delete(target):
                from nova_cli.local.contextifier import run_contextify
                run_contextify(os.getcwd(), save_to_disk=True)

    def cmd_clean(self, args):
            if not self.state.loaded_files:
                self.interface.print("[yellow]No files loaded. Use :load first.[yellow]")
                return

            api = BridgeyeAPIClient()
            repo_map = prompts.get_repo_map_cached(os.getcwd())

            for filename, content in self.state.loaded_files.items():
                try:
                    self.interface.print(f"[dim]Refactoring {filename}...[/dim]")

                    resp = api.refactor(
                        filename=filename,
                        content=content,
                        model="moonshotai/kimi-k2.6",
                        provider="openrouter",
                        repo_map=repo_map
                    )

                    file_path = (
                        self.state.loaded_paths.get(filename)
                        or (
                            self.state.active_file
                            if self.state.active_file and os.path.basename(self.state.active_file) == filename
                            else filename
                        )
                    )

                    # --- CASE 1: Janitor returned a Nova [EDIT] patch ---
                    if isinstance(resp, str) and "[EDIT:" in resp:
                        # Quick client-side sanity check before applying
                        if "<<<<<<<" not in resp or "=======" not in resp or ">>>>>>>" not in resp:
                            self.interface.print(
                                f"[bold red]Invalid Janitor patch format for {filename} (missing SEARCH/REPLACE markers).[/bold red]"
                            )
                            self.interface.print((resp or "")[:2000])
                            continue

                        modified = handle_ai_commands(resp)

                        if not modified:
                            self.interface.print(
                                f"[bold red]Janitor returned an [EDIT] block but it could not be applied for {filename}.[/bold red]"
                            )
                            self.interface.print((resp or "")[:2000])
                            continue

                        # Reload file after successful patch
                        if os.path.exists(file_path):
                            with open(file_path, "r", encoding="utf-8") as f:
                                new_code = f.read()
                            self.state.loaded_files[filename] = new_code

                        self.interface.print(f"[green]✔ Refactored {filename}[/green]")
                        continue

                    # --- CASE 2: Server returned plain refactored code (fallback path) ---
                    # This should almost never happen now that Janitor is strict,
                    # but we keep it as a safety fallback.
                    if not isinstance(resp, str):
                        raise ValueError(f"Unexpected response type from Janitor: {type(resp)}")

                    new_code = resp or ""

                    with open(file_path, "w", encoding="utf-8") as f:
                        f.write(new_code)

                    self.state.loaded_files[filename] = new_code
                    self.interface.print(f"[green]✔ Refactored {filename}[/green]")

                except Exception as e:
                    self.interface.print(f"[bold red]Janitor API error for {filename}:[/bold red] {e}")    

    def cmd_continue(self, args):
            """Resumes an interrupted build from the last failed file."""
            if not self.state.pending_build_files:
                self.interface.print("[yellow]>> No pending build queue found.[/yellow]")
                return

            model = self.state.current_build_model or "moonshotai/kimi-k2.6"
            self.interface.print(f"[cyan]>> Resuming build with {model}: {len(self.state.pending_build_files)} files remaining...[/cyan]")
            self.interface.display_coding_mode(model)
            self._execute_build_loop(override_model=model)

--- FILE: cli/shell_parts/ui_utils.py ---

import os
import re
import sys

from nova_cli import config
def extract_enhanced_prompt(raw_output: str) -> str:
        import re
        # Strip model special tokens e.g. <|end|>, <|start|>, <|channel|>
        raw_output = re.sub(r'<\|[^|>]*\|>', '', raw_output)
        # Case 1: Both tags present
        matches = re.findall(r"<enhanced_prompt\s*>(.*?)</enhanced_prompt\s*>", raw_output, re.DOTALL | re.IGNORECASE)
        if matches:
            return matches[-1].strip()
        # Case 2: Opening tag only
        match = re.search(r".*<enhanced_prompt\s*>(.*)", raw_output, re.DOTALL | re.IGNORECASE)
        if match:
            return match.group(1).strip()
        # Case 3: No tags — thinking uses plain "Task:", actual content uses **Task** (bold)
        idx = raw_output.find('**Task**')
        if idx != -1:
            return raw_output[idx:].strip()
        return raw_output.strip()
class ShellUIUtilsMixin:
    

    def get_prompt_text(self):
        prefix = ""
        if self.state.active_file:
            prefix = f"[dim]({os.path.basename(self.state.active_file)})[/dim] "
        if len(self.state.loaded_files) > 0:
            prefix += f"[dim][{len(self.state.loaded_files)} loaded][/dim] "
        
        # Color changes to RED in overdrive mode
        prompt_color = "bold red" if config.OVERDRIVE else "bold cyan"
        overdrive_indicator = "[bold red]O[/bold red] " if config.OVERDRIVE else ""
        
        return f"{prefix}{overdrive_indicator}[{prompt_color}]spark terminal >[/{prompt_color}]  "

    def _step_start(self, message: str):
            self.interface.print(f"[cyan]• {message}[/cyan]")

    def _step_ok(self, message: str):
            self.interface.print(f"[green]✓ {message}[/green]")

    def _step_warn(self, message: str):
            self.interface.print(f"[yellow]⚠ {message}[/yellow]")

    def _step_fail(self, message: str):
            self.interface.print(f"[red]✗ {message}[/red]")

    def scan_and_load_context(self, text):
                potential_files = re.findall(r"\b[\w\-\/]+\.\w+\b", text)

                loaded_any = False
                for fname in potential_files:
                    if os.path.exists(fname) and os.path.isfile(fname):
                        try:
                            abs_path = os.path.abspath(fname).replace("\\", "/")
                            base = os.path.basename(fname)

                            with open(fname, "r", encoding="utf-8") as f:
                                self.state.loaded_files[base] = f.read()
                            self.state.loaded_paths[base] = abs_path

                            if not self.state.active_file:
                                self.state.active_file = abs_path

                            loaded_any = True
                        except Exception:
                            pass

                if self.state.active_file and os.path.exists(self.state.active_file):
                    try:
                        base = os.path.basename(self.state.active_file)
                        with open(self.state.active_file, "r", encoding="utf-8") as f:
                            self.state.loaded_files[base] = f.read()
                        self.state.loaded_paths[base] = os.path.abspath(self.state.active_file).replace("\\", "/")
                    except Exception:
                        pass

                if loaded_any:
                    self.interface.print("[dim]>> Auto-loaded file context for AI visibility.[/dim]") 

--- FILE: local/file_manager.py ---

# Facade module to preserve old imports:
# import modules.file_manager as file_manager

from nova_cli.local.file_manager import *  # noqa: F401,F403


--- FILE: local/ui.py ---

from rich.console import Console, Group
from rich.panel import Panel
from rich.markdown import Markdown
from rich.live import Live
from rich.table import Table
from rich.rule import Rule
from rich import box
import os
import questionary
import requests
from questionary import Separator
from nova_cli import __version__, config
from nova_cli.nova_core.ai.utils import MODELS  # CLI model selector list
from nova_cli.local.file_manager.git_ops import get_repo

class Interface:
    def __init__(self):
        # Removed fixed width=120 to allow responsiveness to terminal size
        self.console = Console(force_terminal=True)
        self._update_status = None

    def print(self, *args, **kwargs):
        # We set soft_wrap=True as default unless specifically told otherwise
        if "soft_wrap" not in kwargs:
            kwargs["soft_wrap"] = True
        self.console.print(*args, **kwargs)

    def input(self, prompt_text):
        """Renders prompt with Rich and uses prompt_toolkit for perfect scrolling and wrapping."""
        from prompt_toolkit import prompt
        from prompt_toolkit.formatted_text import ANSI
        
        with self.console.capture() as capture:
            self.console.print(prompt_text, end="")
        raw_prompt = capture.get()
        
        try:
            return prompt(ANSI(raw_prompt))
        except (EOFError, KeyboardInterrupt):
            raise
    
    def script_output(self, text: str, title: str = "Terminal Output", color: str = "white"):
        """Displays script output in a structured, pretty terminal panel."""
        if not text or not text.strip():
            return
        
        from rich.text import Text
        # Use markup=False to preserve raw data/logs exactly as they appeared
        content = Text(text.strip(), style=color) 
        
        self.console.print(Panel(
            content,
            title=f"[bold]{title}[/bold]",
            border_style="bright_black",
            padding=(1, 2),
            subtitle="[dim]Execution complete[/dim]",
            subtitle_align="right"
        ))

    def display_error(self, message: str, title: str = "System Error"):
        """Renders a prettified error message, hiding technical noise."""
        import re
        # Clean JSON structures and raw error codes
        clean_msg = str(message)
        if "{" in clean_msg and "}" in clean_msg:
            # Extract message from JSON-like strings if possible, else generic fallback
            match = re.search(r"['\"]message['\"]:\s*['\"](.*?)['\"]", clean_msg)
            clean_msg = match.group(1) if match else "An unexpected internal error occurred."
        
        # Replace common technical codes with user-friendly language
        if "403" in clean_msg:
            clean_msg = "Access denied. Please ensure you are logged in or check your network settings."
        elif "504" in clean_msg or "503" in clean_msg or "500" in clean_msg:
            clean_msg = "The server is currently unreachable or timed out. Please check your internet connection."
        
        # Remove Provider-specific prefixes
        clean_msg = re.sub(r"^[A-Z]+\sProvider\sError:\s*", "", clean_msg)
        clean_msg = re.sub(r"^Error\scode:\s\d+\s-\s*", "", clean_msg)

        self.console.print(Panel(
            f"[bold white]{clean_msg}[/bold white]",
            title=f"[bold red] {title} [/bold red]",
            border_style="red",
            padding=(1, 2),
        ))

    def display_interpretation(self, interpretation_text: str):
        """Displays AI-generated data insights in a specialized panel."""
        if not interpretation_text:
            return
        self.console.print(Panel(
            Markdown(interpretation_text.strip()),
            title="[bold magenta]NOVA DATA INTERPRETATION[/bold magenta]",
            border_style="magenta",
            padding=(1, 2)
        ))

    def create_loader(self, text=""):
        # Bigger dots12 spinner matching spark terminal bold cyan color
        return self.console.status(
            f"[bold cyan]{text}[/bold cyan]",
            spinner="dots12",
            spinner_style="cyan",
            speed=1.0
        )

    def show_model_selector(self, current_model: str):
        # Build (provider, model) pairs so we can return both
        options = []
        for provider, models in MODELS.items():
            for m in models:
                options.append((provider, m))

        # Show only model names in UI, but keep provider in the value
        choices = [
            questionary.Choice(title=model, value=(provider, model))
            for provider, model in options
        ]

        # Default selection
        default_value = None
        for provider, model in options:
            if model == current_model:
                default_value = (provider, model)
                break

        answer = questionary.select(
            "Model:",
            choices=choices,
            default=default_value,
            style=questionary.Style([
                ("qmark", "fg:#00ffff bold"),
                ("question", "fg:#ffffff bold"),
                ("answer", "fg:#00ffff bold"),
                ("pointer", "fg:#00ffff bold"),
                ("selected", "fg:#00ffff"),
            ]),
        ).ask()

        if not answer:
            # keep current model, assume openrouter if unknown
            return current_model, "openrouter"

        provider, model = answer
        return model, provider



    def show_git_options(self, auto_commit, auto_push):
        """Displays the Git Configuration Menu with dynamic Init option."""
        # Check if repo exists to dynamically change the menu
        has_repo = get_repo() is not None

        state_ac = "🟢 ON " if auto_commit else "🔴 OFF"
        state_ap = "🟢 ON " if auto_push else "🔴 OFF"

        choices = [
            Separator("--- AUTOMATION SETTINGS ---"),
            f"Toggle Auto-Commit  [{state_ac}]",
            f"Toggle Auto-Push    [{state_ap}]",
            
            Separator("--- ACTIONS ---"),
        ]

        if not has_repo:
            choices.append("📦 Initialize Git Repository")
        else:
            choices.extend([
                "📝 Manual Commit (Stage & Commit all)",
                "🚀 Push to Origin",
                "⬇️  Pull from Origin",
                "📊 Git Status",
            ])
            
        choices.extend([
            Separator("--- EXIT ---"),
            "🔙 Back to Terminal"
        ])

        answer = questionary.select(
            "Git Operations Center",
            choices=choices,
            style=questionary.Style([
                ('qmark', 'fg:#00ff00 bold'),       
                ('question', 'fg:#ffffff bold'),    
                ('answer', 'fg:#00ff00 bold'),      
                ('pointer', 'fg:#00ff00 bold'),     
                ('selected', 'fg:#00ff00'),
                ('separator', 'fg:#666666'),
            ]),
            use_indicator=True
        ).ask()
        
        if not answer: return "Back"
        if "Initialize" in answer: return "Initialize"
        if "Back" in answer: return "Back"
        if "Auto-Commit" in answer: return "Toggle Auto-Commit"
        if "Auto-Push" in answer: return "Toggle Auto-Push"
        if "Manual Commit" in answer: return "Manual Commit"
        if "Push" in answer: return "Push"
        if "Pull" in answer: return "Pull"
        if "Force Sync" in answer: return "Force Sync"
        if "Git Status" in answer: return "Git Status"
        
        return answer

    def get_multiline_input(self):
        self.print("[dim]Paste code below. Type 'EOF' to finish.[/dim]")
        lines = []
        while True:
            try:
                line = self.console.input()
                if line.strip().upper() == "EOF": break
                lines.append(line)
            except KeyboardInterrupt:
                return ""
        return "\n\n".join(lines)

    def clear(self):
        self.console.clear()

    def display_interpretation(self, interpretation_text: str):
        """Displays AI-generated data insights in a specialized panel."""
        if not interpretation_text:
            return
        self.console.print(Panel(
            Markdown(interpretation_text.strip()),
            title="[bold magenta]NOVA DATA INTERPRETATION[/bold magenta]",
            border_style="magenta",
            padding=(1, 2)
        ))

    def display_coding_mode(self, model_name: str):
        """Displays a high-visibility banner for the selected implementation model."""
        display_name = "KIMI k2.6"
        
        self.console.print(Panel(
            f"[bold white]CORE UPGRADE:[/bold white] [bold purple]{display_name} ACTIVE[/bold purple]\n"
            f"[dim]Mode: Surgical Implementation | Model: {model_name}[/dim]",
            border_style="purple",
            expand=False
        ))

    def display_blueprint(self, blueprint_text: str):
        """Displays the AST-based repository map."""
        from rich.syntax import Syntax
        syntax = Syntax(blueprint_text, "python", theme="monokai", line_numbers=False, word_wrap=True)
        self.console.print(Panel(
            syntax,
            title="[bold cyan]Project Blueprint (Repository Map)[/bold cyan]",
            border_style="cyan",
            padding=(1, 2)
        ))

    def render_ai_response(self, text: str):
        """Renders AI markdown response with high-fidelity formatting."""
        if not text:
            return
        
        # Parse as Markdown to properly render tables, headers, and formatting
        md = Markdown(text.strip())
        
        self.console.print()  # Vertical spacer
        self.console.print(md)
        # Visual divider to separate response from the next input prompt
        self.console.print("[dim]────────────────────────────────────────────────────────────────────────────────[/dim]")

    def render_help_menu(self, sections: dict):
        """Renders a responsive, structured help menu using Tables."""
        table = Table(box=None, show_header=False, padding=(0, 2), expand=True)
        table.add_column("Command", style="bold cyan", no_wrap=True, width=15)
        table.add_column("Description", style="dim")

        for section_title, commands in sections.items():
            table.add_row(f"\n[bold magenta]{section_title}[/bold magenta]")
            for cmd, desc in commands.items():
                table.add_row(cmd, desc)

        self.console.print(Panel(table, title="[bold white]NOVA HELP CENTER[/bold white]", border_style="cyan", padding=(1, 2)))

    def render_ai_response(self, text: str):
        if not text:
            return
        
        # Markdown handles tables and headers responsively
        md = Markdown(text.strip())
        self.console.print() 
        self.console.print(md)
        # Rule automatically expands to fill the current terminal width
        self.console.print(Rule(style="dim"))

    def _get_update_status(self):
        """Checks API URL to toggle Developer Mode or Update notifications."""
        if self._update_status:
            return self._update_status

        api_url = config.NOVA_API_BASE_URL.lower()
        is_localhost = "localhost" in api_url or "127.0.0.1" in api_url

        # 1. Always establish the current version display first
        current_version_display = f"[dim]v{__version__}[/dim]"

        if is_localhost:
            self._update_status = f"[bold green]Local Build[/bold green] {current_version_display}"
            return self._update_status

        # 2. Production / Remote environment: Check for updates on PyPI
        try:
            response = requests.get("https://pypi.org/pypi/nova-bridgeye/json", timeout=1.5)
            latest_version = response.json()["info"]["version"]

            # Helper function to convert "0.1.5.1" into (0, 1, 5, 1) for accurate math comparison
            def parse_ver(v):
                return tuple(map(int, (v.split("."))))

            # Only notify if PyPI version is strictly greater than installed version
            if parse_ver(latest_version) > parse_ver(__version__):
                self._update_status = (
                    f"{current_version_display}  [bold yellow]Update Available: v{latest_version}[/bold yellow]\n"
                    f"[dim]Run: pip install --upgrade nova-bridgeye[/dim]"
                )
            else:
                self._update_status = f"{current_version_display} [dim](Up to date)[/dim]"
        except Exception:
            # Fallback if PyPI is unreachable or parsing fails
            self._update_status = current_version_display

        return self._update_status

    def display_startup_hint(self, logged_in: bool = True):
        """Displays a visually appealing prompt based on login status."""
        if logged_in:
            msg = "Welcome back! Type [bold cyan]help[/bold cyan] to explore available commands and features."
        else:
            msg = "Welcome to [bold cyan]NOVA[/bold cyan]! Type [bold cyan]login[/bold cyan] to connect your account and start building."
            
        self.console.print(Panel(
            msg,
            border_style="bright_black",
            padding=(0, 2),
            expand=False
        ))

    def display_header(self, model_name, cwd):
        self.clear()
        
        # Build Top Header Row (Left: Identity & CWD, Right: Version Status)
        header_table = Table.grid(expand=True)
        # vertical="top" keeps alignment perfect even if there is no update
        header_table.add_column(justify="left", vertical="top")
        header_table.add_column(justify="right", vertical="top")
        
        identity_and_cwd = f"[bold white]NOVA[/bold white] [dim]│[/dim] [cyan]{model_name}[/cyan]\n[dim]{cwd}[/dim]"
        version_info = self._get_update_status()
        
        header_table.add_row(identity_and_cwd, version_info)

        self.console.print(Rule(style="dim"))
        self.console.print(header_table)
        self.console.print(Rule(style="dim"))

    def stream_response(self, chat_generator):
        full_text = ""
        self.print() 
        
        from rich.spinner import Spinner
        spinner = Spinner("dots12", text="[bold cyan]NOVA is thinking...[/bold cyan]", speed=1.5)

        with Live(spinner, refresh_per_second=15, auto_refresh=True, vertical_overflow="visible") as live:
            chunk_counter = 0
            for chunk in chat_generator:
                if chunk.text:
                    full_text += chunk.text
                    chunk_counter += 1
                    if chunk_counter % 4 == 0:
                        live.update(Markdown(full_text))
            
            if full_text:
                live.update(Markdown(full_text))
        
        self.print() 
        return full_text

    def stream_rich_response(self, chat_generator, show_reasoning: bool = True):
        """Beautified stream for models with reasoning (Thinking) capabilities."""
        full_content = ""
        thought_content = ""
        self.print()

        from rich.spinner import Spinner
        spinner = Spinner("dots12", text="[bold cyan]NOVA is thinking...[/bold cyan]", speed=1.5)

        with Live(spinner, refresh_per_second=15, auto_refresh=True, vertical_overflow="visible") as live:
            for event in chat_generator:
                if event.get("type") == "chunk":
                    text = event.get("text", "")
                    is_reasoning = event.get("is_reasoning", False)

                    if is_reasoning:
                        if show_reasoning:
                            thought_content += text
                    else:
                        full_content += text

                    # Build the display group
                    display_parts = []
                    if thought_content and show_reasoning:
                        display_parts.append(Panel(thought_content.strip(), title="[dim]NOVA Thinking...[/dim]", border_style="dim", style="dim"))
                    if full_content:
                        display_parts.append(Markdown(full_content))
                    
                    if not display_parts:
                        live.update(spinner)
                    else:
                        live.update(Group(*display_parts))
                elif event.get("type") == "error":
                    err_msg = event.get("error", "").lower()
                    if any(k in err_msg for k in ["rate_limit", "413", "tpm"]):
                        raise RuntimeError("The model is very busy due to high demand. Please switch to another model using :model.")
                    if "504" in err_msg:
                        raise RuntimeError("Connection timed out. Please check your internet and try again.")
                    if "403" in err_msg:
                        raise RuntimeError("Access denied. Please check your internet connection or login status.")
                    raise RuntimeError("An unexpected error occurred during the build sequence.")

        # Ensure a clean break after implementation tasks
        self.print()
        
        # Fallback: If the model mistakenly outputs everything as reasoning, return the thought content so edits aren't lost.
        final_result = full_content if full_content.strip() else thought_content
        return final_result

ui = Interface()

--- FILE: local/utils.py ---

import re
import ast
import os
from typing import List, Tuple, Optional

def extract_code_from_markdown(md_text: str) -> Optional[str]:
    """
    Finds the most relevant code block. Uses greedy matching to handle nested blocks 
    (e.g. a Markdown plan containing smaller code snippets).
    """
    # Greedy match to capture outermost block if nested
    pattern = r"```(?:\w+)?\s*\n?([\s\S]*)```"
    match = re.search(pattern, md_text)
    
    if match:
        return match.group(1).strip()
    
    # Fallback to non-greedy findall if greedy failed
    pattern_fallback = r"```(?:\w+)?\s*(.*?)```"
    matches = re.findall(pattern_fallback, md_text, re.DOTALL)
    if not matches:
        return None

    candidates = []
    
    # Analyze matches to score them
    for content in matches:
        lines = content.strip().splitlines()
        line_count = len(lines)
        
        # Heuristic: Detect shell commands
        is_shell = False
        first_word = lines[0].strip().split()[0] if lines and lines[0].strip() else ""
        if first_word.lower() in ["pip", "python", "npm", "cd", "ls", "nova", "git", "bash", "sh"]:
            is_shell = True
            
        candidates.append({
            "content": content.strip(),
            "lines": line_count,
            "is_shell": is_shell
        })

    # Filter: If we have multiple blocks, discard short shell blocks
    if len(candidates) > 1:
        filtered = [c for c in candidates if not (c["is_shell"] and c["lines"] < 5)]
        if filtered:
            candidates = filtered

    # Sort by length (descending) - Assume the actual code is the largest chunk
    candidates.sort(key=lambda x: x["lines"], reverse=True)

    return candidates[0]["content"]

def parse_multiple_files(text: str) -> List[Tuple[str, str]]:
    """
    Parses text for multiple [CREATE: filename] ... content pairs.
    
    Robustness: 
    1. Handles bolding/whitespace in tags.
    2. FIRST tries to find a Markdown code block.
    3. FALLBACK: If no code block is found, captures text but STOPS at conversational markers.
    
    Args:
        text (str): The text to parse for [CREATE: filename] ... content pairs.
    
    Returns:
        List[Tuple[str, str]]: A list of tuples containing the filename and content.
    """
    files_to_create: List[Tuple[str, str]] = []
    
    # 1. Split text by the [CREATE: filename] tag
    # This divides the text into [preamble, filename1, content1, filename2, content2...]
    split_pattern = r"(?:\*\*|__)?\[CREATE:\s*(.*?)\s*\](?:\*\*|__)?"
    segments = re.split(split_pattern, text, flags=re.IGNORECASE)
    
    if len(segments) < 3:
        return []

    # Iterate starting from index 1 (first filename), taking steps of 2
    for i in range(1, len(segments), 2):
        filename = segments[i].strip()
        following_text = segments[i+1]
        
        # Clean up filename (remove potential trailing punctuation)
        filename = filename.rstrip(".:,")
        
        # Strategy A: Look for explicit Markdown Code Block (Preferred)
        # We use a greedy match ([\s\S]*) to capture internal code blocks (mermaid, etc.)
        # We then manually strip the very last set of triple backticks if they exist.
        match = re.search(r"```(?:\w+)?\s*\n?([\s\S]*)", following_text)
        
        if match:
            # We capture everything after the first ```
            code = match.group(1).strip()
            
            # IMPROVED LOGIC: Only strip the closing fence if it's the 
            # VERY LAST thing in the segment. This prevents truncating 
            # at internal code blocks (like JSON snippets in a PLAN.md).
            if code.endswith("```"):
                code = code[:-3].strip()
            elif "```" in code:
                # If there's a fence but not at the end, the model likely 
                # stopped mid-sentence or used internal blocks. 
                # We search for the outermost closing fence.
                potential_end = code.rfind("```")
                # If there is conversational text after the last fence, 
                # we cut at the last fence.
                if potential_end != -1:
                    code = code[:potential_end].strip()
                
            files_to_create.append((filename, code))
        else:
            # Strategy B (Fallback): The AI forgot backticks. 
            # We take the raw text and stop ONLY if we see another NOVA tag.
            # This ensures Markdown headers (#) and bold (**) aren't cut off.
            raw_content = following_text.strip()
            
            # If the next tag exists in this block, cut the content there
            next_tag = re.search(r"\[(?:CREATE|EDIT|MKDIR|DELETE):", raw_content, re.IGNORECASE)
            if next_tag:
                raw_content = raw_content[:next_tag.start()].strip()
            
            if raw_content:
                files_to_create.append((filename, raw_content))
            
    return files_to_create

def check_syntax(content: str, filename: str) -> Tuple[bool, str]:
    """
    Verifies if the code content is valid syntax for Python, Node.js, or R.
    """
    import subprocess
    import tempfile

    ext = os.path.splitext(filename)[1].lower()
    
    if ext == ".py":
        try:
            ast.parse(content)
            return True, ""
        except SyntaxError as e:
            return False, f"Line {e.lineno}: {e.msg}"
        except Exception as e:
            return False, str(e)

    elif ext in [".js", ".mjs", ".ts"]:
        # Use node --check for a dry-run syntax validation
        with tempfile.NamedTemporaryFile(suffix=ext, delete=False, mode='w', encoding='utf-8') as tmp:
            tmp.write(content)
            tmp_path = tmp.name
        try:
            node_bin = "node.exe" if os.name == "nt" else "node"
            res = subprocess.run([node_bin, "--check", tmp_path], capture_output=True, text=True)
            os.remove(tmp_path)
            if res.returncode != 0:
                return False, res.stderr
            return True, ""
        except Exception:
            return True, "" # Fallback if node isn't installed

    return True, ""

def generate_ast_map(startpath: str) -> str:
    """
    Scans the directory and generates a high-level map of the code structure
    (Classes and Functions) using AST, skipping bodies to save tokens.
    """
    repo_map = []
    
    excluded_dirs = {
        ".git", "__pycache__", "venv", "env", "node_modules", 
        ".idea", ".vscode", "dist", "build", ".next", ".ds_store", 
        ".mypy_cache", ".nova"
    }

    for root, dirs, files in os.walk(startpath):
        # Filter directories in-place
        dirs[:] = [d for d in dirs if d not in excluded_dirs and not d.startswith(".")]
        
        # Define extensions we want to show in the map
        trackable_extensions = (".py", ".html", ".css", ".js", ".ts", ".r", ".json", ".md")

        for file in files:
            if not file.endswith(trackable_extensions):
                continue
                
            full_path = os.path.join(root, file)
            rel_path = os.path.relpath(full_path, startpath).replace("\\", "/")
            
            # Case 1: Python Files (AST parsing for structure)
            if file.endswith(".py"):
                try:
                    with open(full_path, 'r', encoding='utf-8') as f:
                        content = f.read()
                        if not content.strip(): continue
                        tree = ast.parse(content)
                    
                    repo_map.append(f"FILE: {rel_path}")
                    
                    for node in tree.body:
                        if isinstance(node, ast.ClassDef):
                            repo_map.append(f"  class {node.name}:")
                            for sub in node.body:
                                if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and not sub.name.startswith("_"):
                                    args = [a.arg for a in sub.args.args]
                                    sig = f"def {sub.name}({', '.join(args)})"
                                    prefix = "    async " if isinstance(sub, ast.AsyncFunctionDef) else "    "
                                    repo_map.append(f"{prefix}{sig}")
                        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                            args = [a.arg for a in node.args.args]
                            sig = f"def {node.name}({', '.join(args)})"
                            prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
                            repo_map.append(f"  {prefix}{sig}")
                            
                except Exception:
                    repo_map.append(f"FILE: {rel_path} (Parse Error)")
            
            # Case 2: Node.js / R / Web (Lightweight symbol extraction)
            elif file.endswith((".js", ".ts", ".r", ".R")):
                repo_map.append(f"FILE: {rel_path}")
                try:
                    with open(full_path, 'r', encoding='utf-8') as f:
                        lines = f.readlines()
                    for line in lines:
                        # Match JS functions: function name() or const name = () =>
                        js_func = re.search(r'(?:function\s+([\w\d_]+)|(?:const|let|var)\s+([\w\d_]+)\s*=\s*(?:async\s*)?\(.*?\)\s*=>)', line)
                        if js_func:
                            name = js_func.group(1) or js_func.group(2)
                            repo_map.append(f"  function {name}()")
                        
                        # Match R functions: name <- function(...)
                        r_func = re.search(r'([\w\d\._]+)\s*(?:<-|=)\s*function\s*\(', line)
                        if r_func:
                            repo_map.append(f"  function {r_func.group(1)}()")
                except: pass
            else:
                repo_map.append(f"FILE: {rel_path}")
                
    return "\n".join(repo_map)

def strip_ansi(text: str) -> str:
    """Aggressive cleaner that removes ANSI and broken terminal fragments."""
    if not text: return ""
    import re
    # 1. Standard ANSI Escape codes
    ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
    text = ansi_escape.sub('', text)
    
    # 2. Fix broken fragments (like [1m or [0m appearing as literal text)
    text = re.sub(r'\[\d+(?:\;\d+)*m', '', text)
    
    # 3. Remove Carriage Returns (\r) which cause line overwriting/backspacing artifacts
    text = text.replace('\r', '')
    
    # 4. Keep only printable characters to ensure the UI stays clean
    return "".join(ch for ch in text if ch.isprintable() or ch in "\n\t")

def get_platform_install_cmd(tool_name: str) -> str:
    """Returns the OS-specific installation command for a given tool."""
    import platform
    import sys
    os_type = platform.system().lower()
    
    # Winget flags: --silent (no UI), --accept-source-agreements, --accept-package-agreements
    win_flags = "--silent --accept-source-agreements --accept-package-agreements"

    installers = {
        "node": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": "brew install node",
            "linux": "sudo apt-get update && sudo apt-get install -y nodejs npm"
        },
        "npm": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": "brew install node",
            "linux": "sudo apt-get update && sudo apt-get install -y npm"
        },
        "npx": {
            "windows": f"winget install OpenJS.NodeJS {win_flags}",
            "darwin": "brew install node",
            "linux": "sudo apt-get update && sudo apt-get install -y nodejs npm"
        },
        "Rscript": {
            "windows": f"winget install RProject.R {win_flags}",
            "darwin": "brew install r",
            "linux": "sudo apt-get update && sudo apt-get install -y r-base"
        },
        "streamlit": {
            "windows": f"{sys.executable} -m pip install streamlit",
            "darwin": f"{sys.executable} -m pip install streamlit",
            "linux": f"{sys.executable} -m pip install streamlit"
        }
    }

    if tool_name in installers:
        target_os = "darwin" if os_type == "darwin" else ("windows" if os_type == "windows" else "linux")
        return installers[tool_name].get(target_os, "")
    
    return ""

def refresh_environment_variables():
    """Reloads PATH from the OS across Windows, Mac, and Linux without restarting."""
    import os
    import sys
    import platform
    
    os_type = platform.system().lower()

    if os_type == "windows":
        try:
            import winreg
            paths = []
            # Pull from User and System Registry
            for hkey, subkey in [(winreg.HKEY_CURRENT_USER, "Environment"), 
                                (winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment")]:
                with winreg.OpenKey(hkey, subkey) as key:
                    try:
                        raw_path, _ = winreg.QueryValueEx(key, "Path")
                        paths.extend(raw_path.split(os.pathsep))
                    except FileNotFoundError:
                        continue
            
            # Merge and de-duplicate
            current_path = os.environ.get("PATH", "").split(os.pathsep)
            updated_path = list(dict.fromkeys(current_path + paths)) 
            os.environ["PATH"] = os.pathsep.join(updated_path)
        except Exception:
            pass
            
    elif os_type in ["darwin", "linux"]:
        # Standard binary locations that installers (brew/apt) target
        standard_bins = [
            "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin",
            "/opt/homebrew/bin", "/opt/homebrew/sbin", # Mac Brew
            os.path.expanduser("~/.local/bin"),        # Linux/Python user bins
            os.path.expanduser("~/bin")
        ]
        current_path = os.environ.get("PATH", "").split(os.pathsep)
        updated_path = list(dict.fromkeys(current_path + standard_bins))
        os.environ["PATH"] = os.pathsep.join(updated_path)

def ensure_dependencies(file_path: str, tool_path: str = None):
    """Universal entry point to scan and install dependencies for Python, R, and Node."""
    ext = os.path.splitext(file_path)[1].lower()
    if ext == ".py":
        _ensure_python_deps(file_path)
    elif ext in [".r", ".R"]:
        _ensure_r_deps(file_path, tool_path)
    elif ext == ".js":
        _ensure_node_deps(file_path, tool_path)

def _ensure_python_deps(file_path: str):
    import importlib.metadata
    import sys
    import subprocess
    from nova_cli.local.ui import ui

    ui.print(f"[dim]>> Pre-scanning Python dependencies for {os.path.basename(file_path)}...[/dim]")
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()

    imports = re.findall(r"^(?:import|from)\s+([\w\d_]+)", content, re.MULTILINE)
    unique_imports = set(imports)
    std_lib = {"os", "sys", "re", "time", "json", "datetime", "math", "random", "shutil", "subprocess", "shlex", "ast", "logging", "io", "base64", "collections", "itertools", "functools", "pathlib", "threading", "queue", "pickle", "csv"}
    
    missing = []
    for mod in unique_imports:
        if mod in std_lib: continue
        try:
            importlib.metadata.version(mod)
        except importlib.metadata.PackageNotFoundError:
            mapping = {"sklearn": "scikit-learn", "cv2": "opencv-python", "PIL": "Pillow", "yaml": "pyyaml"}
            missing.append(mapping.get(mod, mod))
            
    if missing:
        ui.print(f"[bold cyan]>> Environment Check: Found {len(missing)} missing Python packages.[/bold cyan]")
        try:
            subprocess.check_call([sys.executable, "-m", "pip", "install"] + missing)
            ui.print("[bold green]>> Python environment synchronized.[/bold green]")
        except Exception:
            for pkg in missing:
                try: subprocess.check_call([sys.executable, "-m", "pip", "install", pkg], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
                except Exception: pass

def _ensure_r_deps(file_path: str, tool_path: str = None):
    import subprocess
    from nova_cli.local.ui import ui
    ui.print(f"[dim]>> Pre-scanning R dependencies for {os.path.basename(file_path)}...[/dim]")
    
    with open(file_path, "r", encoding="utf-8") as f:
        content = f.read()
    
    pkgs = re.findall(r"(?:library|require)\s*\(\s*\"?([\w\d\.]+)\"?\s*\)", content)
    if pkgs:
        ui.print(f"[bold cyan]>> Environment Check: Ensuring R packages: {', '.join(set(pkgs))}[/bold cyan]")
        
        # Use the resolved tool_path if provided, else fallback to 'Rscript'
        r_bin = f'"{tool_path}"' if tool_path else "Rscript"
        
        for pkg in set(pkgs):
            # Logic: 
            # 1. Create a user-writable library directory if it doesn't exist
            # 2. Tell R to install the package there
            r_cmd = (
                f"dir.create(Sys.getenv('R_LIBS_USER'), showWarnings=FALSE, recursive=TRUE); "
                f".libPaths(Sys.getenv('R_LIBS_USER')); "
                f"if(!require('{pkg}')) install.packages('{pkg}', repos='https://cloud.r-project.org', lib=.libPaths()[1])"
            )
            
            # Wrap in quotes for shell execution
            full_cmd = f'{r_bin} -e "{r_cmd}"'
            subprocess.call(full_cmd, shell=True)
            
        ui.print("[bold green]>> R environment synchronized (User Library).[/bold green]")

def _ensure_node_deps(file_path: str, tool_path: str = None):
    import subprocess
    from nova_cli.local.ui import ui
    if not os.path.exists("package.json"):
        ui.print(f"[dim]>> Pre-scanning Node dependencies for {os.path.basename(file_path)}...[/dim]")
        with open(file_path, "r", encoding="utf-8") as f:
            content = f.read()
        mods = re.findall(r"(?:import|require)\s*\(?\s*['\"]([\w\d\.\-\/]+)['\"]", content)
        if mods:
            ui.print(f"[bold cyan]>> Environment Check: Installing npm modules...[/bold cyan]")
            # Use npm.cmd on Windows for better resolution
            npm_bin = "npm.cmd" if os.name == "nt" else "npm"
            subprocess.call(f"{npm_bin} install " + " ".join(set(mods)), shell=True)

--- FILE: local/healer/__init__.py ---



--- FILE: local/healer/runner.py ---

# nova_cli/local/healer/runner.py

import os
import time
import subprocess
from typing import Dict, List, Optional
import py_compile
import tempfile


from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.ui import ui
import core.prompts as prompts


def _syntax_check(path: str) -> tuple[bool, str]:
    try:
        py_compile.compile(path, doraise=True)
        return True, ""
    except Exception as e:
        return False, str(e)


def _read_text(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def _write_text(path: str, content: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)


def _strip_utf8_bom(path: str) -> bool:
    try:
        with open(path, "rb") as f:
            raw = f.read()
        if raw.startswith(b"\xef\xbb\xbf"):
            with open(path, "wb") as f:
                f.write(raw[3:])
            return True
    except Exception:
        return False
    return False


def _ensure_target_file_in_context(command_args: List[str], cwd: str, ctx: Dict[str, str]) -> None:
    """
    Ensure the executed target file is present in context with canonical keys.
    """
    if not command_args:
        return

    target = command_args[-1]
    if not (isinstance(target, str) and "." in target):
        return
    
    raw_abs = os.path.abspath(os.path.join(cwd, target))
    if not os.path.exists(raw_abs) or os.path.isdir(raw_abs):
        return

    # Canonical Windows Path: D:/path/to/file.ext
    drive, rest = os.path.splitdrive(raw_abs)
    abs_path = (drive.upper() + rest).replace("\\", "/")

    try:
        with open(raw_abs, "r", encoding="utf-8", errors="replace") as f:
            content = f.read().replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
    except Exception:
        return

    # Map only canonical Absolute and Relative paths
    ctx[abs_path] = content
    try:
        rel = os.path.relpath(raw_abs, cwd).replace("\\", "/")
        ctx[rel] = content
    except Exception:
        pass


def _run_once(command_args: List[str], cwd: str) -> tuple[int, str, str]:
    """Runs the process and streams output to terminal in real-time."""
    stdout_lines = []
    stderr_lines = []
    
    # Use shell=True on Windows for command wrappers (.cmd/.bat)
    # Use a string command on Windows when shell=True for better resolution
    use_shell = os.name == "nt"
    cmd = " ".join(f'"{a}"' if " " in a else a for a in command_args) if use_shell else command_args
    
    try:
        process = subprocess.Popen(
            cmd,
            cwd=cwd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            stdin=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="replace",
            bufsize=1,
            universal_newlines=True,
            shell=use_shell
        )
    except FileNotFoundError:
        return 127, "", f"Nova Error: The runner executable '{command_args[0]}' could not be found in the system PATH."

    import threading
    def stream_reader(pipe, container):
        for line in iter(pipe.readline, ''):
            if line:
                container.append(line)
                # Raw print removed here to allow Rich UI to handle the display once.
        pipe.close()

    t1 = threading.Thread(target=stream_reader, args=(process.stdout, stdout_lines))
    t2 = threading.Thread(target=stream_reader, args=(process.stderr, stderr_lines))
    
    t1.start()
    t2.start()
    
    exit_code = process.wait()
    t1.join()
    t2.join()

    return exit_code, "".join(stdout_lines), "".join(stderr_lines)


def run_with_healing(
    *,
    command_args: List[str],
    cwd: str,
    model: str,
    provider: str,
    context: Optional[Dict[str, str]] = None,
    extra_context: Optional[Dict[str, str]] = None,
    repo_map: Optional[str] = None,
    max_attempts: int = 3,
) -> str:
    """
    Runs the command locally.
    On failure: calls NOVA_API /healer/run to get a patch block ([EDIT]/[SHELL]).
    Applies the patch locally via handle_ai_commands(), then retries.
    Returns "SUCCESS_SIGNAL" on success.
    Raises on final failure.
    """

    from nova_cli.local.contextifier import run_contextify

    merged_context: Dict[str, str] = {}
    if context:
        merged_context.update(context)
    if extra_context:
        merged_context.update(extra_context)
        
    try:
        from nova_cli.local.contextifier import run_contextify
        project_ctx = run_contextify(cwd, save_to_disk=False)
        merged_context.update(project_ctx)
    except Exception:
        pass

    if repo_map is None:
        try:
            repo_map = prompts.get_repo_map_cached(cwd)
        except Exception:
            repo_map = None

    api = BridgeyeAPIClient()
    last_err = ""

    # Best-effort: infer target file path
    target_abs: Optional[str] = None
    if command_args and isinstance(command_args[-1], str) and command_args[-1].lower().endswith((".py", ".js", ".mjs", ".ts", ".r", ".R")):
        t = command_args[-1]
        target_abs = t if os.path.isabs(t) else os.path.abspath(os.path.join(cwd, t))
        target_abs = target_abs.replace("\\", "/")

    # Bounded history for prompt stability
    healing_history: List[str] = []
    MAX_HISTORY_ITEMS = 6
    MAX_HISTORY_CHARS = 2000

    # Redundant scan removed. Dependency check is managed by the shell before execution.

    def _push_history(item: str) -> None:
        if not item:
            return
        healing_history.append(item[:MAX_HISTORY_CHARS])
        if len(healing_history) > MAX_HISTORY_ITEMS:
            del healing_history[:-MAX_HISTORY_ITEMS]

    def _norm_path(p: str) -> str:
        return (p or "").replace("\\", "/")

    def _basename(p: str) -> str:
        return os.path.basename(_norm_path(p))

    def _resolve_modified_paths(modified: List[str]) -> List[str]:
        """
        Convert returned modified file identifiers into real on-disk paths when possible.
        Returns absolute, normalized paths for files that exist.
        """
        out: List[str] = []
        for f in modified or []:
            if not f or f == "SYSTEM_ENVIRONMENT" or not isinstance(f, str):
                continue

            f_norm = _norm_path(f)

            candidates = []
            if os.path.isabs(f_norm):
                candidates.append(f_norm)
            else:
                candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, f_norm))))

            # Also try basename in cwd if AI returned weird relative fragments
            candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, os.path.basename(f_norm)))))

            found = None
            for c in candidates:
                if os.path.exists(c) and os.path.isfile(c):
                    found = c
                    break

            if found and found not in out:
                out.append(found)

        return out

    def _snapshot_files(paths: List[str]) -> Dict[str, str]:
        snap: Dict[str, str] = {}
        for p in paths:
            try:
                snap[p] = _read_text(p)
            except Exception:
                pass
        return snap

    def _revert_files(snapshot: Dict[str, str]) -> None:
        for p, content in snapshot.items():
            try:
                _write_text(p, content)
            except Exception:
                pass

    for attempt in range(1, max_attempts + 1):
        ui.print(f"[dim]>> Run attempt {attempt}/{max_attempts}: {' '.join(command_args)}[/dim]")

        exit_code, stdout, stderr = _run_once(command_args, cwd)
        full_logs = (stdout or "") + (stderr or "")
        last_err = stderr or stdout or f"exit_code={exit_code}"

        if exit_code == 0:
            # If successful, we return the full logs so the Interpretation Phase has data
            return full_logs if full_logs.strip() else "SUCCESS_SIGNAL"

        err_text = (stderr or "") + "\n" + (stdout or "")

        # Local hotfix: strip UTF-8 BOM bytes that can break Python
        if (
            ("U+FEFF" in err_text or "invalid non-printable character" in err_text)
            and target_abs
            and os.path.exists(target_abs)
        ):
            if _strip_utf8_bom(target_abs):
                ui.print("[yellow]>> Detected UTF-8 BOM (U+FEFF). Removed BOM and retrying...[/yellow]")
                prompts.clear_file_tree_cache()
                _ensure_target_file_in_context(command_args, cwd, merged_context)
                continue

        # Force a fresh read from disk to avoid "search_not_found" errors
        prompts.clear_file_tree_cache()
        
        # REBUILD CONTEXT FROM SCRATCH - Pure state, no shell pollution
        merged_context.clear()
        
        try:
            from nova_cli.local.contextifier import run_contextify
            # Generate/update project context file in the current working directory before healing
            project_ctx = run_contextify(cwd, save_to_disk=True)
            if isinstance(project_ctx, dict):
                merged_context.update(project_ctx)
            elif isinstance(project_ctx, str):
                # Inject string dumps as a virtual file so the AI can read it
                merged_context["PROJECT_CONTEXT_SUMMARY.txt"] = project_ctx
        except Exception: pass
        
        _ensure_target_file_in_context(command_args, cwd, merged_context)

        clean_stdout = (stdout or "").replace("\r\n", "\n").replace("\r", "\n")
        clean_stderr = (stderr or "").replace("\r\n", "\n").replace("\r", "\n")

        # Detect language to prevent misclassification in Healer
        lang = "Python"
        package_manager = "pip install"
        if any(arg.endswith((".r", ".R")) for arg in command_args): 
            lang = "R-Language"
            package_manager = "Rscript -e 'install.packages(...)'"
        elif any(arg.endswith(".js") for arg in command_args): 
            lang = "Node.js"
            package_manager = "npm install"

        with ui.create_loader(f"NOVA is analyzing {lang} logs and generating a fix..."):
            # Inject strict language instructions into stderr for the AI
            lang_instruction = (
                f"CRITICAL: The current language is {lang}.\n"
                f"You MUST use {package_manager} if a library is missing.\n"
                "DO NOT suggest Python packages for R or Node errors.\n"
                "CRITICAL: For [EDIT: filename] blocks, you MUST use the relative path (e.g. src/repositories/index.js). Do NOT use absolute paths.\n"
                "CRITICAL: Your SEARCH block must be EXACTLY 1 or 2 lines of original code. Do NOT use '...' or '// ...' to skip lines.\n"
                "CRITICAL: The SEARCH block must match the original file exactly, including all spaces and indentation."
            )
            
            # Normalize line endings to \n to guarantee match with API validator
            # AND expand keys to include absolute paths just in case AI hallucinates them
            expanded_ctx = {}
            for k, v in list(merged_context.items()):
                normalized_v = v.replace("\r\n", "\n").replace("\r", "\n")
                expanded_ctx[k] = normalized_v
                if not os.path.isabs(k):
                    abs_p = os.path.abspath(os.path.join(cwd, k))
                    expanded_ctx[abs_p] = normalized_v
                    expanded_ctx[abs_p.replace("\\", "/")] = normalized_v
            
            merged_context.clear()
            merged_context.update(expanded_ctx)
            
            # CRITICAL FIX: Clear the history so the AI doesn't hallucinate old code
            # after the file was successfully mutated in Attempt 1.
            if isinstance(healing_history, list):
                healing_history.clear()
            
            patch_block = api.run_with_healing(
                command=command_args,
                cwd=cwd,
                model="openai/gpt-oss-120b",
                provider="openrouter",
                exit_code=exit_code,
                stdout=clean_stdout,
                stderr=f"{lang_instruction}\n{clean_stderr}",
                context=merged_context,
                repo_map=repo_map,
                healing_history=[],  # Force empty history
            )

        if not patch_block or not patch_block.strip():
            ui.display_error(
                "NOVA could not generate a valid repair patch for this error. \n\n[cyan]Please report or give feedback on support@bridgeye.com.[/cyan]",
                title="Healing Failure"
            )
            return "FAILURE_SIGNAL"

        ui.print("[cyan]>> Healer suggested patch. Applying...[/cyan]")

        # Apply patch
        modified = handle_ai_commands(patch_block, cwd=cwd)

        # Record what we tried (ONCE)
        _push_history(patch_block)

        # If nothing applied, add a corrective hint and retry
        if not modified:
            _push_history(
                "EDIT_NOT_APPLIED: CLI could not apply patch. "
                "CAUSE: SEARCH not found or invalid SEARCH/REPLACE structure. "
                "NEXT: Copy SEARCH lines verbatim from FILE_CONTEXT, include exact indentation."
            )
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # Resolve real modified file paths and snapshot for possible revert
        modified_paths = _resolve_modified_paths(modified)
        snapshot = _snapshot_files(modified_paths)

        # Syntax check: if any modified .py file fails compile, revert and retry
        syntax_failed = False
        syntax_err = ""
        for p in modified_paths:
            if p.lower().endswith(".py"):
                ok, err = _syntax_check(p)
                if not ok:
                    syntax_failed = True
                    syntax_err = f"{os.path.basename(p)}: {err}"
                    break

        if syntax_failed:
            ui.print("[red]>> Edit Rejected: Resulting code has Syntax Error.[/red]")
            ui.print(f"[red]>> {syntax_err}[/red]")
            _revert_files(snapshot)
            _push_history(f"SYNTAX_ERROR_AFTER_PATCH: {syntax_err}")
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # After file ops, clear repo-map cache + refresh context for modified files
        prompts.clear_file_tree_cache()

        # REFRESH CONTEXT: Strictly re-read all modified files from disk
        for p in modified_paths:
            try:
                with open(p, "r", encoding="utf-8") as fp:
                    content = fp.read()
                
                content = content.replace("\r\n", "\n").replace("\r", "\n")
                if content.startswith("\ufeff"):
                    content = content.lstrip("\ufeff")

                abs_key = _norm_path(p)
                rel_key = None
                try:
                    rel_key = os.path.relpath(p, cwd).replace("\\", "/")
                except Exception:
                    pass

                # Remove all possible stale versions of this file from the dictionary
                # Use lower() to handle Windows case-insensitivity during refresh
                p_base = _basename(p).lower()
                keys_to_clear = [k for k in merged_context.keys() if _basename(k).lower() == p_base]
                for k in keys_to_clear:
                    del merged_context[k]

                # Re-insert fresh content
                merged_context[abs_key] = content
                if rel_key:
                    merged_context[rel_key] = content
            except Exception:
                pass

        # Force a refresh of the target file context specifically
        _ensure_target_file_in_context(command_args, cwd, merged_context)

    raise RuntimeError(f"Command failed after {max_attempts} attempts.\nLast error:\n{last_err}")



--- FILE: local/file_manager/__init__.py ---

from nova_cli.local.file_manager.git_ops import (
    update_repo_path,
    commit_changes,
    manual_commit,
    perform_push,
    perform_pull,
    git_status,
)

from nova_cli.local.file_manager.path_ops import (
    validate_path,
    resolve_path,
)

from nova_cli.local.file_manager.edit_ops import (
    apply_surgical_edit,
    fuzzy_replace,
    normalize_line,
)

from nova_cli.local.file_manager.io_ops import (
    create_backup,
    map_directory,
    get_project_files,
    run_creation_wizard,
    show_diff,
    safe_delete,
    safe_write,
    save_code_to_file,
    load_file,
)

from nova_cli.local.file_manager.commands import (
    handle_ai_commands,
    is_placeholder_path,
)


--- FILE: local/file_manager/commands.py ---

# nova_cli\local\file_manager\commands.py
import os
import re
from typing import Optional
import shlex
import subprocess

import core.prompts as prompts  # keep as-is for now
from nova_cli.local.utils import parse_multiple_files

from nova_cli.local.file_manager.io_ops import safe_delete, safe_write
from nova_cli.local.file_manager.edit_ops import apply_surgical_edit
from nova_cli.local.file_manager.path_ops import validate_path


IGNORE_DIRS = {"path", "folder", "directory", "filename", "your_path", "project_name"}


def _normalize_text(s: str) -> str:
    if s is None:
        return ""
    s = s.replace("\r\n", "\n").replace("\r", "\n")
    s = s.lstrip("\ufeff")  # remove BOM if present
    return s


def _strip_code_fences(s: str) -> str:
    """
    Cleans content by removing Markdown code fences (```python ... ```).
    Also aggressively strips reasoning tags (<thought>, [THOUGHT]) that may have leaked inside.
    """
    s = _normalize_text(s).strip()
    
    # 1. Strip reasoning tags that models like Qwen or Kimi occasionally include
    s = re.sub(r"<(?:/)?thought>", "", s, flags=re.IGNORECASE)
    s = re.sub(r"\[(?:/)?THOUGHT\]", "", s, flags=re.IGNORECASE)
    # Aggressively remove anything inside thought tags if the AI was verbose
    s = re.sub(r"<thought>.*?</thought>", "", s, flags=re.DOTALL | re.IGNORECASE)
    s = re.sub(r"\[THOUGHT\].*?\[/THOUGHT\]", "", s, flags=re.DOTALL | re.IGNORECASE)

    # 2. Artifact Cleanup: Strip leaked surgical markers (SEARCH/REPLACE) if they exist in full file creation
    s = re.sub(r"<{4,10}\s*SEARCH\n?", "", s, flags=re.IGNORECASE)
    s = re.sub(r"={4,10}\n?", "", s)
    s = re.sub(r">{4,10}\s*REPLACE\n?", "", s, flags=re.IGNORECASE)

    # 3. Broad cleanup: Remove any triple backticks + language tags anywhere in the string
    # This prevents fences from breaking SEARCH/REPLACE comparisons
    s = re.sub(r"```[a-zA-Z0-9_+\-]*", "", s)
    s = s.replace("```", "")
    
    return s.strip()



def is_placeholder_path(path: str) -> bool:
    """Detects documentation placeholders to avoid executing help examples."""
    path_lower = path.lower().replace("\\", "/")
    if path_lower in ["example.py", "file.ext", "path/to/target", "path/to/dir", "path/to/file.ext"]:
        return True
    if "path/to/" in path_lower:
        return True
    return False


def handle_ai_commands(text: str, errors: Optional[list[str]] = None, cwd: Optional[str] = None) -> list[str]:
    """
    Parses and executes AI commands ([MKDIR], [DELETE], [EDIT], [CREATE], [SHELL]).
    Returns a list of file paths that were modified.
    """
    import sys
    from nova_cli.local.ui import ui  # Added import here
    modified_files: list[str] = []
    errors = errors or []

    # 1) MKDIR
    mkdir_matches = re.findall(r"(?:\*\*|__)?\[MKDIR:\s*(.*?)\s*\](?:\*\*|__)?", text, re.IGNORECASE)
    folder_created = False

    for folder in mkdir_matches:
        try:
            folder = folder.strip()
            if not folder:
                continue
            if folder.lower() in IGNORE_DIRS:
                continue
            if is_placeholder_path(folder):
                continue

            full_path = validate_path(folder)
            
            if os.path.exists(full_path):
                ui.print(f"[bold red]>> Error: Folder '{folder}' already exists. Creation aborted.[/bold red]")
                continue
                
            os.makedirs(full_path, exist_ok=True)
            ui.print(f"[bold green]>> Created Directory: {folder}[/bold green]")
            folder_created = True

        except PermissionError as e:
            ui.print(f"[bold red]{e}[/bold red]")
        except Exception as e:
            ui.print(f"[red]>> Failed to create directory {folder}: {e}[/red]")

    if folder_created:
        prompts.clear_file_tree_cache()

    # 2) DELETE
    delete_matches = re.findall(r"(?:\*\*|__)?\[DELETE:\s*(.*?)\s*\](?:\*\*|__)?", text, re.IGNORECASE)
    for target in delete_matches:
        target = target.strip()
        if is_placeholder_path(target):
            continue
        # Pass the deletion record back to the shell state manager
        if safe_delete(target):
            modified_files.append(f"DELETED:{target}")

    # 3) EDIT
    edit_split_pattern = r"(?:\*\*|__)?\[EDIT:\s*(.*?)\s*\](?:\*\*|__)?"
    edit_segments = re.split(edit_split_pattern, text, flags=re.IGNORECASE)

    if len(edit_segments) >= 3:
        for i in range(1, len(edit_segments), 2):
            raw_name = edit_segments[i].strip().replace('"', "").replace("'", "").replace("\\", "/")
            
            # Automated Discovery: Resolve path via Global Map if not absolute
            from nova_cli.local.file_manager.path_ops import resolve_path
            filename = resolve_path(raw_name)
            
            if is_placeholder_path(filename):
                continue

            # JIT Loading: If file exists but isn't active, notify user of discovery
            if os.path.exists(filename) and not os.path.isabs(raw_name):
                try:
                    display_name = os.path.relpath(filename).replace("\\", "/")
                except Exception:
                    display_name = filename
                ui.print(f"[dim]>> Automated Discovery: Resolved {raw_name} to {display_name}[/dim]")

            following_text = edit_segments[i + 1]

            block_pattern = r"<{4,10}(?:\s*SEARCH)?\s*\n?(.*?)\n?={4,10}\s*\n?(.*?)\n?>{4,10}(?:\s*REPLACE)?"
            blocks = re.findall(block_pattern, following_text, re.DOTALL)

            if not blocks:
                ui.print(f"[yellow]>> [EDIT:{filename}] called but no valid SEARCH/REPLACE blocks found.[/yellow]")
                errors.append(f"EDIT_FAILED_NO_BLOCKS file={filename}")
                continue

            edits_made = False

            for search_block, replace_block in blocks:
                search_clean = _normalize_text(_strip_code_fences(search_block))
                replace_clean = _normalize_text(_strip_code_fences(replace_block))

                if apply_surgical_edit(filename, search_clean, replace_clean):
                    edits_made = True
                    if filename not in modified_files:
                        modified_files.append(filename)
                else:
                    # If one block in a file fails, the rest are likely invalid now
                    errors.append(f"BLOCK_FAILED file={filename}")
                    break 

            if edits_made:
                prompts.clear_file_tree_cache()
                pass

    # 4) CREATE
    files_to_create = parse_multiple_files(text)

    if files_to_create:
        for filename, code in files_to_create:
            if cwd and not os.path.isabs(filename):
                filename = os.path.abspath(os.path.join(cwd, filename))
            if is_placeholder_path(filename):
                continue

            # CRITICAL: Strip any code fences that the AI might have nested
            code = _strip_code_fences(code)

            if safe_write(filename, code):
                modified_files.append(filename)
                prompts.clear_file_tree_cache()
    else:
        if "[CREATE:" in text and "[EDIT:" not in text:
            if not any(is_placeholder_path(m) for m in re.findall(r"\[CREATE:\s*(.*?)\s*\]", text)):
                ui.print("[yellow]>> Commands found but parsing failed. Ensure code blocks follow [CREATE] tags.[/yellow]")
                errors.append("CREATE_FAILED_PARSE_MULTIFILE")

    # 5) SHELL (Dependency Fixer)
    shell_matches = re.findall(r"\[SHELL:\s*(.*?)\s*\]", text, re.IGNORECASE)
    for shell_cmd in shell_matches:
        shell_cmd = shell_cmd.strip()
        if "install" in shell_cmd.lower():
            try:
                # Extract package names intelligently
                cmd_parts = shlex.split(shell_cmd)
                
                if "pip" in shell_cmd.lower():
                    packages = [p for p in cmd_parts if p not in ["pip", "install", "-m", "python"]]
                    ui.print(f"[bold cyan]>> Auto-Installing Python Packages: {' '.join(packages)}[/bold cyan]")
                    subprocess.check_call([sys.executable, "-m", "pip", "install"] + packages)
                elif "npm" in shell_cmd.lower():
                    # Preserve all parts after 'npm install'
                    pkg_start_idx = 2 if cmd_parts[1] == "install" else 1
                    packages = cmd_parts[pkg_start_idx:]
                    ui.print(f"[bold cyan]>> Auto-Installing Node Packages: {' '.join(packages)}[/bold cyan]")
                    # Use npm.cmd on Windows for reliable execution
                    npm_bin = "npm.cmd" if os.name == "nt" else "npm"
                    subprocess.check_call([npm_bin, "install"] + packages)
                
                ui.print("[bold green]>> Environment updated successfully.[/bold green]")
                modified_files.append("SYSTEM_ENVIRONMENT")
            except Exception as e:
                ui.print(f"[red]>> Installation failed: {e}[/red]", soft_wrap=True)
                errors.append(f"SHELL_INSTALL_FAILED err={str(e)}")
        else:
            ui.print("[red]>> Blocked: Only 'pip install' commands are permitted.[/red]", soft_wrap=True)
            errors.append(f"SHELL_BLOCKED cmd={shell_cmd}")
    return modified_files

--- FILE: local/file_manager/edit_ops.py ---

import os
from nova_cli.local.utils import check_syntax
from nova_cli.local.file_manager.path_ops import resolve_path, validate_path

def normalize_line(line: str) -> str:
    """Removes all whitespace and lowercases to create a comparison fingerprint."""
    return "".join(line.split()).lower()


def fuzzy_replace(content: str, search_block: str, replace_block: str, strict: bool = True) -> tuple[bool, str]:
    """
    Advanced fuzzy matcher that ignores leading/trailing whitespace and empty lines.
    If strict=False, it aggressively strips ALL internal whitespace and case differences.
    It finds the logical match and applies the replacement while attempting to
    preserve the original file's relative indentation.
    """
    import re
    content_lines = content.splitlines()
    # Filter out empty lines from search to handle AI omissions
    search_lines_raw = [l for l in search_block.strip().splitlines() if l.strip()]
    if not search_lines_raw:
        return False, content
    
    if strict:
        search_lines_norm = [l.strip() for l in search_lines_raw]
    else:
        # Aggressively remove all spaces, tabs, and standardize to lowercase
        search_lines_norm = [re.sub(r'\s+', '', l).lower() for l in search_lines_raw]
    
    # We create a map of content lines that are NOT empty
    content_map = [] # list of (original_index, normalized_text)
    for idx, line in enumerate(content_lines):
        if line.strip():
            if strict:
                content_map.append((idx, line.strip()))
            else:
                content_map.append((idx, re.sub(r'\s+', '', line).lower()))
            
    search_len = len(search_lines_norm)
    match_start_in_map = -1
    
    for i in range(len(content_map) - search_len + 1):
        # Compare normalized segments
        if [pair[1] for pair in content_map[i : i + search_len]] == search_lines_norm:
            match_start_in_map = i
            break
            
    if match_start_in_map == -1:
        return False, content

    # Get the actual line indices in the original file
    start_line_idx = content_map[match_start_in_map][0]
    end_line_idx = content_map[match_start_in_map + search_len - 1][0]
    
    # Capture original indentation from the first matched line
    first_line = content_lines[start_line_idx]
    indentation = first_line[:len(first_line) - len(first_line.lstrip())]
    
    # Prepare replacement with correct relative indentation
    replace_lines_raw = replace_block.splitlines()
    replacement_lines = []
    if replace_lines_raw:
        first_replace_line = next((l for l in replace_lines_raw if l.strip()), "")
        ai_indentation = first_replace_line[:len(first_replace_line) - len(first_replace_line.lstrip())]
        
        for l in replace_lines_raw:
            if not l.strip():
                replacement_lines.append(l)
            else:
                if l.startswith(ai_indentation):
                    replacement_lines.append(indentation + l[len(ai_indentation):])
                else:
                    replacement_lines.append(indentation + l.lstrip())
    
    new_lines = content_lines[:start_line_idx] + replacement_lines + content_lines[end_line_idx + 1:]
    return True, "\n".join(new_lines) + "\n"


def apply_surgical_edit(filepath: str, search_block: str, replace_block: str) -> bool:
    """
    Reads file, finds search_block, replaces with replace_block.
    Supports exact match and fuzzy match (whitespace insensitive).
    Performs syntax check before saving.
    """
    # LOCAL IMPORTS to break circular dependency
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes

    full_path = filepath 
    
    if not os.path.isabs(full_path):
        try:
            full_path = validate_path(resolve_path(filepath))
        except Exception:
            full_path = os.path.abspath(filepath)

    if not os.path.exists(full_path):
        ui.print(f"[red]>> Edit Failed: File not found {filepath}[/red]")
        return False

    with open(full_path, "r", encoding="utf-8") as f:
        content = f.read()

    search_block_norm = search_block.replace("\r\n", "\n")
    content_norm = content.replace("\r\n", "\n")

    new_content = None
    method_used = ""

    # Normalize internal line endings and trailing whitespace for a fairer comparison
    search_stripped = "\n".join([l.rstrip() for l in search_block_norm.strip().splitlines()])
    content_stripped = "\n".join([l.rstrip() for l in content_norm.splitlines()])

    # Pass 1: Try Exact Match (Fastest)
    if search_block_norm.strip() in content_norm:
        new_content = content_norm.replace(search_block_norm.strip(), replace_block.strip())
        method_used = "Exact Match"
    
    # Pass 2: Line-by-Line Normalization Match (Resilient to trailing spaces)
    if not new_content:
        search_lines = [l.rstrip() for l in search_block_norm.strip().splitlines()]
        content_lines = [l.rstrip() for l in content_norm.splitlines()]
        
        for i in range(len(content_lines) - len(search_lines) + 1):
            if content_lines[i : i + len(search_lines)] == search_lines:
                orig_lines = content_norm.splitlines()
                reconstructed = orig_lines[:i] + replace_block.strip().splitlines() + orig_lines[i + len(search_lines):]
                new_content = "\n".join(reconstructed) + "\n"
                method_used = "Line-Normalized Match"
                break

    # Pass 3: Indentation-Insensitive Fuzzy Match
    if not new_content:
        success, fuzzy_content = fuzzy_replace(content_norm, search_block_norm, replace_block, strict=True)
        if success:
            new_content = fuzzy_content
            method_used = "Indentation-Insensitive Match"

    try:
        display_name = os.path.relpath(filepath).replace("\\", "/")
    except Exception:
        display_name = os.path.basename(filepath)

    if not new_content:
        # Pass 4: Ultra-Robust Fuzzy Match (Ignores internal spaces & case)
        success, fuzzy_content = fuzzy_replace(content_norm, search_block_norm, replace_block.strip(), strict=False)
        if success:
            new_content = fuzzy_content
            method_used = "Ultra-Robust Match"
        else:
            ui.print(f"[red]>> Edit Failed: SEARCH block not found in {display_name}[/red]")
            ui.print("[dim]   (Tip: The AI might have hallucinated indentation or spacing. Check file content.)[/dim]")
            return False

    is_valid, syntax_error = check_syntax(new_content, full_path)
    if not is_valid:
        ui.print("[bold red]>> Edit Rejected: Resulting code has Syntax Error.[/bold red]")
        ui.print(f"[red]>> {syntax_error}[/red]")
        return False

    with open(full_path, "w", encoding="utf-8") as f:
        f.write(new_content)

    ui.print(f"[green]>> Surgical Edit Applied ({method_used}): {display_name}[/green]")
        
    # Force Git sync for discovered files to maintain repo integrity
    from nova_cli.local.file_manager.git_ops import commit_changes
    commit_changes(f"Surgical edit: {display_name}", full_path, use_semantic=True)
    return True

--- FILE: local/file_manager/git_ops.py ---

# NOVA_CLI/nova_cli/local/file_manager/git_ops.py

import os
from pathlib import Path

import git

from nova_cli import config

from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient

# --- GIT INTEGRATION ---
repo_path: Path = Path(os.getcwd())
repo: git.Repo | None = None


def _default_commit_message() -> str:
    return "chore: update files"

def get_repo():
    """Lazily load the repo only when needed."""
    global repo
    try:
        # Check if current dir or any parent is a git repo
        repo = git.Repo(os.getcwd(), search_parent_directories=True)
        return repo
    except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError):
        repo = None
        return None

def _get_origin_url(r) -> str | None:
    try:
        if "origin" in r.remotes:
            return next(r.remotes.origin.urls, None)
    except Exception:
        pass
    return None
    
def initialize_repo() -> bool:
    from nova_cli.local.ui import ui
    global repo
    try:
        repo = git.Repo.init(os.getcwd())
        
        # PRO MOVE: Create a default .gitignore if it doesn't exist
        gitignore_path = os.path.join(os.getcwd(), ".gitignore")
        if not os.path.exists(gitignore_path):
            with open(gitignore_path, "w") as f:
                f.write(".nova_history\n__pycache__/\n*.pyc\n.env\n")
        
        ui.print("[green]✔ Git repository initialized with default .gitignore.[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]Failed to initialize Git: {e}[/red]")
        return False

def update_repo_path(new_path: str) -> None:
    """Updates the repo object when user CDs without forcing init."""
    global repo, repo_path
    repo_path = Path(new_path)
    repo = get_repo()

def commit_changes(message: str, filepath: str | None = None, use_semantic: bool = False) -> bool:
    from nova_cli.local.ui import ui
    r = get_repo()
    
    # 1. Respect Auto-Commit Toggle
    if not r or not config.GIT_AUTO_COMMIT:
        return False

    try:
        # 2. Stage changes
        if filepath and os.path.exists(filepath):
            r.git.add(filepath)
        else:
            # Stage all modified and deleted files
            r.git.add(update=True)

        # 3. Check for staged changes (Robust check for initial & existing repos)
        has_staged_changes = False
        try:
            # If HEAD exists, diff against it
            if r.index.diff("HEAD"):
                has_staged_changes = True
        except git.exc.BadName:
            # HEAD doesn't exist (Initial Commit) - check if index is not empty
            if len(r.index.entries) > 0:
                has_staged_changes = True

        if has_staged_changes:
            final_msg = f"NOVA: {message}"
            if use_semantic and filepath:
                final_msg = f"chore: update {os.path.basename(filepath)}"

            r.index.commit(final_msg)
            ui.print(f"[dim]>> Auto-Commit: {final_msg}[/dim]")

            # 4. Respect Auto-Push Toggle (Chained)
            if config.GIT_AUTO_PUSH:
                perform_push()
            return True

    except Exception as e:
        ui.print(f"[yellow]>> Git Auto-Commit Error: {e}[/yellow]")

    return False


def manual_commit(model: str | None = None, provider: str | None = None) -> bool:
    """Forces a commit with an option for AI generation or manual input."""
    from nova_cli.local.ui import ui
    import questionary
    
    r = get_repo()
    if not r:
        ui.print("[yellow]>> Not a git repository. Use ':gitoptions' to initialize.[/yellow]")
        return False

    try:
        r.git.add(".")
        # Robust check for changes
        has_changes = False
        try:
            if r.is_dirty(untracked_files=True) or r.index.diff("HEAD"):
                has_changes = True
        except git.exc.BadName:
            if len(r.index.entries) > 0:
                has_changes = True

        if not has_changes:
            ui.print("[dim]>> Nothing to commit (clean working tree).[/dim]")
            return False

        ui.print("[dim]>> Detected changes to commit.[/dim]")
        choice = questionary.select(
            "Choose commit message option:",
            choices=[
                "🤖 Auto-generate commit message",
                "✍️  Enter manually",
            ],
        ).ask()

        if not choice:
            ui.print("[red]>> Commit cancelled.[/red]")
            return False

        commit_msg = None
        if "Auto-generate" in choice:
            try:
                ui.print("[dim cyan]>> Generating commit message...[/dim cyan]")
                diff = r.git.diff(cached=True) or r.git.diff() # Check staged then unstaged

                client = BridgeyeAPIClient()
                commit_msg = client.generate_commit_message(
                    diff_text=diff,
                    model=model or config.DEFAULT_MODEL,
                    provider=provider or "openrouter",
                )
                if not commit_msg.strip():
                    commit_msg = _default_commit_message()
                ui.print(f"[dim]>> AI Commit: {commit_msg}[/dim]")
            except Exception as e:
                ui.print(f"[yellow]>> AI generation failed: {e}[/yellow]")
                commit_msg = _default_commit_message()
        else:
            commit_msg = ui.input("[cyan]Enter commit message:[/cyan] ").strip()
            if not commit_msg:
                commit_msg = _default_commit_message()

        r.index.commit(f"NOVA: {commit_msg}")
        ui.print(f"[green]>> Commit Success: {commit_msg}[/green]")
        return True

    except Exception as e:
        ui.print(f"[red]>> Commit Failed: {e}[/red]")
        return False


def perform_push(model: str | None = None, provider: str | None = None) -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()
    if not r:
        ui.print("[yellow]>> No repository found to push.[/yellow]")
        return

    if not ensure_git_identity(r):
        return

    try:
        # ----------------------------
        # STEP 1: Ensure changes are committed
        # ----------------------------
        manual_commit(model=model, provider=provider)

        # ----------------------------
        # STEP 2: Ensure remote exists
        # ----------------------------
        if not ensure_remote_origin(r):
            return

        # ----------------------------
        # STEP 3: Push
        # ----------------------------
        ui.print("[dim]>> Pushing to origin...[/dim]")
        try:
            branch = r.active_branch.name
            r.git.push("--set-upstream", "origin", branch)
        except Exception:
            r.remotes.origin.push()

        ui.print("[green]>> Push Complete.[/green]")
        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Push Failed: {e}[/red]")


def perform_pull() -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()

    if not r:
        choice = questionary.select(
            "This folder is not a git repository. What would you like to do?",
            choices=[
                "📦 Initialize repository and continue",
                "❌ Cancel",
            ],
        ).ask()

        if not choice or "Cancel" in choice:
            ui.print("[red]>> Pull cancelled.[/red]")
            return

        if not initialize_repo():
            return

        r = get_repo()
        if not r:
            ui.print("[red]>> Failed to initialize repository.[/red]")
            return

    try:
        # 1. Ensure remote exists first
        if not ensure_remote_for_pull(r):
            return

        # 2. Fetch first
        ui.print("[dim]>> Fetching from origin...[/dim]")
        r.remotes.origin.fetch()

        # 3. Warn only now, right before actual pull/checkout
        if r.is_dirty(untracked_files=True):
            ui.print("[yellow]>> You have local changes. Pull may cause conflicts.[/yellow]")

            choice = questionary.select(
                "How would you like to continue?",
                choices=[
                    "⬇️ Continue pull",
                    "🧨 Force sync with origin",
                    "❌ Cancel",
                ],
            ).ask()

            if not choice or "Cancel" in choice:
                ui.print("[red]>> Pull cancelled.[/red]")
                return

            if "Force sync" in choice:
                force_sync_with_origin()
                return

        # 4. Normal pull path
        try:
            branch = r.active_branch.name
            ui.print(f"[dim]>> Pulling branch '{branch}' from origin...[/dim]")
            r.git.pull("origin", branch)
            ui.print("[green]>> Pull Complete.[/green]")

            origin_url = _get_origin_url(r)
            if origin_url:
                ui.print(f"[dim]>> Remote:[/dim] {origin_url}")
            return

        except Exception:
            pass

        # 5. Fallback for fresh repos with no checked-out branch yet
        remote_head = None
        try:
            remote_head = r.git.symbolic_ref("refs/remotes/origin/HEAD")
            remote_head = remote_head.split("/")[-1].strip()
        except Exception:
            pass

        if not remote_head:
            for candidate in ("main", "master"):
                try:
                    r.git.rev_parse(f"origin/{candidate}")
                    remote_head = candidate
                    break
                except Exception:
                    continue

        if not remote_head:
            ui.print("[red]>> Could not determine remote default branch.[/red]")
            return

        ui.print(f"[dim]>> Checking out '{remote_head}' from origin...[/dim]")
        try:
            r.git.checkout("-b", remote_head, f"origin/{remote_head}")
        except Exception:
            r.git.checkout(remote_head)

        ui.print("[green]>> Pull Complete.[/green]")

        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Pull Failed: {e}")


def git_status() -> None:
    from nova_cli.local.ui import ui
    r = get_repo()
    if not r:
        ui.print("[dim]>> Not a git repository.[/dim]")
        return
    try:
        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]Remote:[/dim] {origin_url}")

        if r.is_dirty(untracked_files=True):
            ui.print(f"[yellow]{r.git.status()}[/yellow]")
        else:
            ui.print("[green]Git Status: Clean working tree.[/green]")
    except Exception as e:
        ui.print(f"[red]{e}[/red]")

def ensure_git_identity(r) -> bool:
    from nova_cli.local.ui import ui

    try:
        name = r.config_reader().get_value("user", "name")
        email = r.config_reader().get_value("user", "email")
        if name and email:
            return True
    except Exception:
        pass

    ui.print("[yellow]>> Git user identity not configured.[/yellow]")

    name = ui.input("[cyan]Enter your Git username:[/cyan] ").strip()
    email = ui.input("[cyan]Enter your Git email:[/cyan] ").strip()

    if not name or not email:
        ui.print("[red]>> Git identity setup cancelled.[/red]")
        return False

    r.config_writer().set_value("user", "name", name).release()
    r.config_writer().set_value("user", "email", email).release()

    ui.print("[green]>> Git identity configured.[/green]")
    return True
    
def ensure_remote_origin(r) -> bool:
    from nova_cli.local.ui import ui
    import questionary

    if "origin" in r.remotes:
        return True

    ui.print("[yellow]>> No remote 'origin' found.[/yellow]")

    choice = questionary.select(
        "How would you like to set up remote?",
        choices=[
            "🔗 Use existing repository URL",
            "🆕 Create new GitHub repository (recommended)",
        ],
    ).ask()

    if not choice:
        ui.print("[red]>> Remote setup cancelled.[/red]")
        return False

    if "existing" in choice:
        repo_url = ui.input("[cyan]Enter remote repository URL:[/cyan] ").strip()
        if not repo_url:
            ui.print("[red]>> Cancelled.[/red]")
            return False

    else:
        repo_name = ui.input("[cyan]Enter new repository name:[/cyan] ").strip()
        if not repo_name:
            ui.print("[red]>> Cancelled.[/red]")
            return False

        is_private = questionary.select(
            "Choose repository visibility:",
            choices=[
                "🔒 Private",
                "🌍 Public",
            ],
            default="🔒 Private",
        ).ask()

        if not is_private:
            ui.print("[red]>> Repository creation cancelled.[/red]")
            return False

        private_flag = is_private.startswith("🔒")

        ui.print("[dim]>> Creating GitHub repository...[/dim]")

        try:
            client = BridgeyeAPIClient()
            result = client.create_github_repo(
                repo_name=repo_name,
                private=private_flag,
                description="Created via NOVA CLI",
            )

            repo_url = (result.get("clone_url") or "").strip()
            if not repo_url:
                raise RuntimeError("GitHub repo created but clone_url missing")

            visibility = "private" if private_flag else "public"
            ui.print(f"[green]>> GitHub {visibility} repo created: {result.get('html_url')}[/green]")

        except Exception as e:
            msg = str(e)
            if "already exists" in msg.lower() or "name already exists" in msg.lower():
                ui.print("[red]>> Repository with this name already exists on GitHub. Try another name.[/red]")
            else:
                ui.print(f"[red]>> Failed to create GitHub repo: {e}[/red]")
            return False
    

    try:
        r.create_remote("origin", repo_url)
        ui.print(f"[green]>> Remote added: {repo_url}[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]>> Failed to add remote: {e}[/red]")
        return False

def ensure_remote_for_pull(r) -> bool:
    from nova_cli.local.ui import ui

    if "origin" in r.remotes:
        return True

    ui.print("[yellow]>> No remote 'origin' found.[/yellow]")
    repo_url = ui.input("[cyan]Enter existing repository URL to pull from:[/cyan] ").strip()

    if not repo_url:
        ui.print("[red]>> Pull cancelled.[/red]")
        return False

    try:
        r.create_remote("origin", repo_url)
        ui.print(f"[green]>> Remote added: {repo_url}[/green]")
        return True
    except Exception as e:
        ui.print(f"[red]>> Failed to add remote: {e}[/red]")
        return False 

def force_sync_with_origin() -> None:
    from nova_cli.local.ui import ui
    import questionary

    r = get_repo()
    if not r:
        ui.print("[yellow]>> Not a git repository.[/yellow]")
        return

    if not ensure_remote_for_pull(r):
        return

    confirm = questionary.confirm(
        "This will discard local changes and hard reset to the remote branch. Continue?",
        default=False,
    ).ask()

    if not confirm:
        ui.print("[red]>> Force sync cancelled.[/red]")
        return

    try:
        ui.print("[dim]>> Fetching from origin...[/dim]")
        r.remotes.origin.fetch()

        branch = None
        try:
            branch = r.active_branch.name
        except Exception:
            pass

        if not branch:
            for candidate in ("main", "master"):
                try:
                    r.git.rev_parse(f"origin/{candidate}")
                    branch = candidate
                    break
                except Exception:
                    continue

        if not branch:
            ui.print("[red]>> Could not determine remote branch for force sync.[/red]")
            return

        ui.print(f"[dim]>> Resetting hard to origin/{branch}...[/dim]")
        r.git.reset("--hard", f"origin/{branch}")
        ui.print("[green]>> Force sync complete.[/green]")

        origin_url = _get_origin_url(r)
        if origin_url:
            ui.print(f"[dim]>> Remote:[/dim] {origin_url}")

    except Exception as e:
        ui.print(f"[red]>> Force sync failed: {e}[/red]")       

--- FILE: local/file_manager/io_ops.py ---

import os
import shutil
import difflib
import datetime

from rich.tree import Tree
from rich.panel import Panel
from rich.prompt import Confirm
from rich.syntax import Syntax
from nova_cli import config

# Moved ui import inside functions
import core.prompts as prompts  # keep as-is for now
from nova_cli.local.utils import check_syntax

from nova_cli.local.file_manager.path_ops import resolve_path, validate_path
# Moved commit_changes import inside functions


def create_backup(filepath: str) -> None:
    """Moves existing file to .nova/backups with timestamp."""
    from nova_cli.local.ui import ui
    try:
        if not os.path.exists(filepath):
            return

        root = os.path.abspath(os.getcwd())
        backup_root = os.path.join(root, ".nova", "backups")

        rel_path = os.path.relpath(filepath, root)
        dest_base = os.path.join(backup_root, rel_path)
        dest_dir = os.path.dirname(dest_base)
        os.makedirs(dest_dir, exist_ok=True)

        timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        dest_path = f"{dest_base}.{timestamp}.bak"

        shutil.copy2(filepath, dest_path)
        ui.print(f"[dim]>> Backup archived: .nova/backups/{rel_path}.{timestamp}.bak[/dim]")
    except Exception as e:
        ui.print(f"[yellow]>> Backup Warning: Could not create backup for {filepath}: {e}[/yellow]")


def map_directory(path: str = ".") -> None:
    """Visualizes file structure."""
    from nova_cli.local.ui import ui
    tree = Tree(f"[bold cyan]{os.path.basename(os.path.abspath(path))}[/bold cyan]")
    try:
        paths = sorted(
            os.listdir(path),
            key=lambda x: (not os.path.isdir(os.path.join(path, x)), x.lower()),
        )
        for entry in paths:
            if entry.startswith(".") or entry == "__pycache__":
                continue

            full_path = os.path.join(path, entry)
            if os.path.isdir(full_path):
                branch = tree.add(f"[bold yellow]📂 {entry}[/bold yellow]")
                try:
                    sub_paths = sorted(os.listdir(full_path))
                    for sub in sub_paths:
                        if sub.startswith(".") or sub == "__pycache__":
                            continue
                        sub_full = os.path.join(full_path, sub)
                        if os.path.isdir(sub_full):
                            branch.add(f"[bold yellow]📂 {sub}[/bold yellow]")
                        else:
                            branch.add(f"[dim]📄 {sub}[/dim]")
                except PermissionError:
                    branch.add("[red]ACCESS DENIED[/red]")
            else:
                tree.add(f"📄 {entry}")
    except Exception as e:
        tree.add(f"[red]Error: {e}[/red]")

    ui.print(Panel(tree, title="Directory Map", border_style="cyan"))


def get_project_files(extension: str = ".py") -> list[str]:
    """Scans the project for files with extension, excluding ignored dirs."""
    excluded = {
        ".git",
        "__pycache__",
        "venv",
        "env",
        "node_modules",
        ".idea",
        ".vscode",
        "dist",
        "build",
        ".next",
        ".ds_store",
        ".mypy_cache",
        ".nova",
    }

    source_files: list[str] = []
    start_path = os.getcwd()

    for root, dirs, files in os.walk(start_path):
        dirs[:] = [d for d in dirs if d not in excluded and not d.startswith(".")]

        for file in files:
            if file.endswith(extension):
                full_path = os.path.join(root, file)
                source_files.append(os.path.relpath(full_path, start_path))

    return sorted(source_files)


def run_creation_wizard() -> None:
    from nova_cli.local.ui import ui
    ui.print(Panel("[bold yellow]creation_wizard.exe initialized[/bold yellow]", border_style="yellow"))

    folder_name = ui.input("[cyan]1. Target Folder Name > [/cyan]").strip()
    if not folder_name:
        return

    files_input = ui.input("[cyan]2. File Names (space separated) > [/cyan]").strip()
    if not files_input:
        return
    file_names = files_input.split()

    extension = ui.input("[cyan]3. Extension (e.g. .py) > [/cyan]").strip()
    if not extension.startswith("."):
        extension = "." + extension

    try:
        target_dir = validate_path(os.path.join(os.getcwd(), folder_name))
        os.makedirs(target_dir, exist_ok=True)
        ui.print(f"[green]>> Folder created: {folder_name}[/green]")

        for name in file_names:
            fname = f"{name}{extension}"
            fpath = validate_path(os.path.join(target_dir, fname))

            if not os.path.exists(fpath):
                with open(fpath, "w", encoding="utf-8") as f:
                    f.write(f"# File: {fname}\n")
                ui.print(f"   [dim]Created: {fname}[/dim]")
            else:
                ui.print(f"   [yellow]Skipped: {fname}[/yellow]")

        prompts.clear_file_tree_cache()

    except Exception as e:
        ui.print(f"[bold red]>> ERROR: {e}[/bold red]")


def show_diff(filepath: str, new_content: str) -> None:
    """Displays diff between existing file and new content."""
    from nova_cli.local.ui import ui
    if not os.path.exists(filepath):
        ui.print("[dim]>> New File (No diff available)[/dim]")
        return

    with open(filepath, "r", encoding="utf-8") as f:
        old_lines = f.readlines()

    new_lines = new_content.splitlines(keepends=True)

    diff = list(
        difflib.unified_diff(
            old_lines,
            new_lines,
            fromfile=f"original/{os.path.basename(filepath)}",
            tofile=f"proposed/{os.path.basename(filepath)}",
        )
    )

    if not diff:
        ui.print("[dim]>> Content is identical.[/dim]")
        return

    diff_text = "".join(diff)
    syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=True)
    ui.print(Panel(syntax, title=f"Changes: {os.path.basename(filepath)}", border_style="yellow"))


def safe_delete(filepath: str) -> bool:
    """Unified delete with security and confirmation."""
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes
    try:
        full_path = validate_path(filepath)
        if not os.path.exists(full_path):
            ui.print(f"[yellow]>> Delete skipped: {filepath} not found.[/yellow]")
            return False

        ui.print(Panel(f"Target: [bold red]{full_path}[/bold red]", title="DELETE CONFIRMATION", style="red"))
        
        # Overdrive bypass
        if config.OVERDRIVE:
            ui.print(f"[bold yellow]>> Overdrive: Auto-confirming deletion of {os.path.basename(full_path)}[/bold yellow]")
            should_delete = True
        else:
            should_delete = Confirm.ask(f">> DELETE {os.path.basename(full_path)} permanently?")

        if should_delete:
            if os.path.isdir(full_path):
                shutil.rmtree(full_path)
            else:
                os.remove(full_path)

            ui.print(f"[bold red]>> DELETED: {os.path.basename(full_path)}[/bold red]")
            prompts.clear_file_tree_cache()
            commit_changes(f"Deleted {os.path.basename(full_path)}", full_path)
            return True
        else:
            ui.print("[dim]>> Delete cancelled.[/dim]")
            return False

    except Exception as e:
        ui.print(f"[bold red]>> DELETE ERROR: {e}[/bold red]")
        return False


def safe_write(filepath: str, content: str) -> bool:
    """
    Unified write enforcing:
    security, syntax checking, diff preview, confirmation, backup, semantic commit.
    """
    from nova_cli.local.ui import ui
    from nova_cli.local.file_manager.git_ops import commit_changes
    try:
        full_path = validate_path(resolve_path(filepath))
    except PermissionError as e:
        ui.print(f"[bold red]{e}[/bold red]")
        return False

    is_valid, syntax_err = check_syntax(content, full_path)
    if not is_valid:
        ui.print(f"[bold red]>> Warning: Code has Syntax Error: {syntax_err}[/bold red]")
        if not Confirm.ask(">> Force write invalid code?"):
            return False

    show_diff(full_path, content)

    ui.print(
        Panel(
            f"Target: [bold cyan]{full_path}[/bold cyan]\nBytes: {len(content)}",
            title="WRITE CONFIRMATION",
            style="yellow",
        )
    )

    prompt_msg = (
        f">> OVERWRITE {os.path.basename(full_path)}?"
        if os.path.exists(full_path)
        else f">> CREATE {os.path.basename(full_path)}?"
    )

    # Overdrive Bypass
    if config.OVERDRIVE:
        ui.print(f"[bold yellow]>> Overdrive: Auto-confirming {os.path.basename(full_path)}[/bold yellow]")
    elif not Confirm.ask(prompt_msg):
        ui.print("[dim]>> Cancelled.[/dim]")
        return False

    try:
        directory = os.path.dirname(full_path)
        if directory and not os.path.exists(directory):
            os.makedirs(directory, exist_ok=True)

        tmp_path = f"{full_path}.tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            f.write(content)

        if os.path.exists(full_path):
            create_backup(full_path)

        os.replace(tmp_path, full_path)

        ui.print(f"[bold green]>> SUCCESS: {os.path.basename(full_path)} written.[/bold green]")
        commit_changes(f"Updated {os.path.basename(full_path)}", full_path, use_semantic=True)

        prompts.clear_file_tree_cache()
        return True

    except Exception as e:
        if "tmp_path" in locals() and os.path.exists(tmp_path):
            os.remove(tmp_path)
        ui.display_error(f"Could not save changes: {e}", title="Write Error")
        return False


def save_code_to_file(active_file: str | None, code_content: str | None) -> None:
    """Saves provided code content to active file."""
    from nova_cli.local.ui import ui
    if not active_file:
        ui.print("[red]>> ERROR: No active file loaded.[/red]")
        return

    if not code_content:
        ui.print("[red]>> ERROR: No stored code found to apply.[/red]")
        return

    safe_write(active_file, code_content)


def load_file(filepath: str) -> tuple[str | None, str]:
    from nova_cli.local.ui import ui
    try:
        full_path = validate_path(resolve_path(filepath))

        if not os.path.exists(full_path):
            ui.print(f"[red]>> NOT FOUND: {filepath}[/red]")
            return None, ""

        with open(full_path, "r", encoding="utf-8") as f:
            content = f.read()

        ui.print(f"[dim cyan]>> UPLOADING {len(content)} BYTES...[/dim cyan]")
        return full_path, content

    except PermissionError as e:
        ui.print(f"[bold red]{e}[/bold red]")
        return None, ""
    except Exception as e:
        ui.print(f"[red]>> READ ERROR: {e}[/red]")
        return None, ""

--- FILE: local/file_manager/path_ops.py ---

import os
from nova_cli.local.file_manager.git_ops import repo


def validate_path(filepath: str) -> str:
    """
    Security Barrier: Ensures the target path is within the established PROJECT_ROOT.
    Prevents directory traversal attacks.
    """
    from nova_cli import config as cli_config
    
    # Boundary is defined by the current set PROJECT_ROOT
    root_boundary = cli_config.PROJECT_ROOT
    target = os.path.abspath(filepath)

    if os.path.commonpath([root_boundary, target]) != root_boundary:
        raise PermissionError(
            f"Access Denied: Operating outside of restricted root: {root_boundary}"
        )

    return target


def resolve_path(filepath: str) -> str:
    """
    Resolves a filepath. 
    1. Prioritizes the current working directory (CWD).
    2. If not found in CWD, searches relative to the Git Root.
    3. Defaults to CWD absolute path for new files.
    """
    # Normalize path separators
    filepath = filepath.replace("\\", "/")
    
    # 1. Check relative to current working directory (exact path first)
    cwd_path = os.path.abspath(filepath)
    if os.path.exists(cwd_path) and os.path.isfile(cwd_path):
        return cwd_path

    # 1.5 Handle AI hallucinating the project root folder name in the path
    cwd_basename = os.path.basename(os.path.abspath(os.getcwd()))
    if filepath.startswith(cwd_basename + "/") or filepath.startswith(cwd_basename + "\\"):
        stripped_path = filepath[len(cwd_basename) + 1:]
        test_path = os.path.abspath(stripped_path)
        if os.path.exists(test_path) and os.path.isfile(test_path):
            return test_path

    # 2. Check relative to Git Root (only if file exists there)
    # This helps find files if the AI uses root-relative paths while we are in a subfolder.
    if repo and repo.working_dir:
        root_path = os.path.abspath(os.path.join(repo.working_dir, filepath))
        if os.path.exists(root_path):
            return root_path

    # 3. Fallback: Return path relative to CWD (for new file creation)
    return cwd_path


--- FILE: local/contextifier/__init__.py ---

from .engine import run_contextify

--- FILE: local/contextifier/engine.py ---

import os

IGNORE_DIRS = {
    ".git", "node_modules", "dist", "build", ".vscode", 
    "__pycache__", ".idea", "venv", ".venv", "env", ".env", 
    ".dart_tool", ".pub-cache", ".pytest_cache", ".mypy_cache", ".venv_obf",
    ".venv_build", "release", "obf", "pyarmor_runtime_000000", "pkg_root",
    "assets", ".nova", "project_context.txt"
}

IGNORE_EXTENSIONS = {
    ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp", ".pdf", 
    ".lock", ".log", ".zip", ".tar", ".gz", ".DS_Store", ".pyc",
    ".arb", ".exe", ".bin", ".dll", ".so", ".dylib", ".class", ".csv", ".xlsx", ".JSON"
}

IGNORE_FILES = {
    "project_context.txt",
    "PLAN.md"
}

def is_binary_file(filepath, chunk_size=1024):
    try:
        with open(filepath, 'rb') as f:
            chunk = f.read(chunk_size)
            if b'\0' in chunk:
                return True
        return False
    except Exception:
        return True

def generate_tree(startpath, ignore_dirs):
    tree_lines = []
    for root, dirs, files in os.walk(startpath, topdown=True):
        dirs[:] = [d for d in dirs if d not in ignore_dirs]
        level = root.replace(startpath, '').count(os.sep)
        indent = ' ' * 4 * level
        folder_name = os.path.basename(root)
        if folder_name == '': folder_name = "."
        tree_lines.append(f"{indent}{folder_name}/")
        subindent = ' ' * 4 * (level + 1)
        for f in sorted(files):
            if f in IGNORE_FILES or any(f.endswith(ext) for ext in IGNORE_EXTENSIONS):
                continue
            tree_lines.append(f"{subindent}{f}")
    return "\n".join(tree_lines)

def run_contextify(start_dir='.', verbose_callback=None, save_to_disk=False) -> dict[str, str]:
    """
    Analyzes the project and returns a dictionary of {relative_path: content}
    for all valid files, following the Contextify exclusion logic.
    """
    context_data = {}
    output_buffer = []

    # Tree Section
    if save_to_disk:
        output_buffer.append("Project file structure:\n=======================\n")
        output_buffer.append(generate_tree(start_dir, IGNORE_DIRS))
        output_buffer.append("\n\n\nFile Contents:\n===============\n")

    for root, dirs, files in os.walk(start_dir, topdown=True):
        # Filter directories in-place
        dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
        
        for file in sorted(files):
            if file in IGNORE_FILES or any(file.endswith(ext) for ext in IGNORE_EXTENSIONS):
                continue

            file_path = os.path.join(root, file)
            relative_path = os.path.relpath(file_path, start_dir).replace(os.sep, '/')

            if is_binary_file(file_path):
                continue

            try:
                if verbose_callback:
                    verbose_callback(relative_path)
                with open(file_path, "r", encoding="utf-8", errors="ignore") as infile:
                    content = infile.read()
                    context_data[relative_path] = content
                    if save_to_disk:
                        output_buffer.append(f"\n\n--- FILE: {relative_path} ---\n\n")
                        output_buffer.append(content)
            except Exception:
                continue

    if save_to_disk:
        with open("project_context.txt", "w", encoding="utf-8") as f:
            f.write("".join(output_buffer))

    return context_data