# gui.py
"""
Note(Gemini):
The main DearPyGui interface orchestrator.
This is not a simple UI wrapper; it's a complex state machine that:
1. Manages background daemon threads for AI requests so the UI doesn't block.
2. Implements lock-protected comms queues for safe main-thread rendering.
3. Pauses AI execution to prompt the human for destructive PowerShell script approval.
"""
# gui.py
import dearpygui.dearpygui as dpg
import tomllib
import tomli_w
import threading
import time
import math
import sys
import os
from pathlib import Path
from tkinter import filedialog, Tk
import aggregate
import ai_client
from ai_client import ProviderError
import shell_runner
import session_logger
import project_manager
import api_hooks
import theme
import mcp_client
from performance_monitor import PerformanceMonitor

CONFIG_PATH = Path("config.toml")
PROVIDERS = ["gemini", "anthropic"]

# Max chars shown inline for a heavy comms field before clamping to a scrollable box
COMMS_CLAMP_CHARS = 300


def load_config() -> dict:
    with open(CONFIG_PATH, "rb") as f:
        return tomllib.load(f)


def save_config(config: dict):
    with open(CONFIG_PATH, "wb") as f:
        tomli_w.dump(config, f)


def hide_tk_root() -> Tk:
    root = Tk()
    root.withdraw()
    root.wm_attributes("-topmost", True)
    return root

def get_total_token_usage() -> dict:
    """Returns aggregated token usage across the entire session from comms log."""
    usage = {
        "input_tokens": 0,
        "output_tokens": 0,
        "cache_read_input_tokens": 0,
        "cache_creation_input_tokens": 0
    }
    for entry in ai_client.get_comms_log():
        if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
            u = entry["payload"]["usage"]
            for k in usage.keys():
                usage[k] += u.get(k, 0) or 0
    return usage

def truncate_entries(entries: list[dict], max_pairs: int) -> list[dict]:
    """Truncates history to the last N pairs of User/AI messages."""
    if max_pairs <= 0:
        return []
    target_count = max_pairs * 2
    if len(entries) <= target_count:
        return entries
    return entries[-target_count:]


# ------------------------------------------------------------------ comms rendering helpers

# Direction -> colour
_DIR_COLORS = {
    "OUT": (100, 200, 255),   # blue-ish
    "IN":  (140, 255, 160),   # green-ish
}

# Kind -> colour
_KIND_COLORS = {
    "request":          (255, 220, 100),
    "response":         (180, 255, 180),
    "tool_call":        (255, 180, 80),
    "tool_result":      (180, 220, 255),
    "tool_result_send": (200, 180, 255),
}

_HEAVY_KEYS = {"message", "text", "script", "output", "content"}

# Label colours used in rich rendering
_LABEL_COLOR  = (180, 180, 180)
_VALUE_COLOR  = (220, 220, 220)
_KEY_COLOR    = (140, 200, 255)   # dict key / call index
_NUM_COLOR    = (180, 255, 180)   # numbers / token counts
_SUBHDR_COLOR = (220, 200, 120)   # sub-section header



def _show_text_viewer(title: str, text: str):
    if dpg.does_item_exist("win_text_viewer"):
        wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
        dpg.configure_item("win_text_viewer", label=f"Text Viewer - {title}", show=True)
        if dpg.does_item_exist("text_viewer_content"):
            dpg.set_value("text_viewer_content", text if text is not None else "")
            dpg.configure_item("text_viewer_content", show=not wrap)
        if dpg.does_item_exist("text_viewer_wrap_container"):
            dpg.set_value("text_viewer_wrap", text if text is not None else "")
            dpg.configure_item("text_viewer_wrap_container", show=wrap)
        dpg.focus_item("win_text_viewer")


def _add_text_field(parent: str, label: str, value: str):
    """Render a labelled text value; long values get a scrollable box."""
    wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
    with dpg.group(horizontal=False, parent=parent):
        with dpg.group(horizontal=True):
            dpg.add_text(f"{label}:", color=_LABEL_COLOR)
            dpg.add_button(label="[+]", callback=lambda s, a, u: _show_text_viewer(label, u), user_data=value)
        if len(value) > COMMS_CLAMP_CHARS:
            if wrap:
                with dpg.child_window(height=80, border=True):
                    # add_input_text for selection
                    dpg.add_input_text(default_value=value, multiline=True, readonly=True, width=-1, height=-1)
            else:
                dpg.add_input_text(
                    default_value=value,
                    multiline=True,
                    readonly=True,
                    width=-1,
                    height=80,
                )
        else:
            # Short selectable text
            dpg.add_input_text(default_value=value if value else "(empty)", readonly=True, width=-1)


def _add_kv_row(parent: str, key: str, val, val_color=None):
    """Single key: value row, horizontally laid out."""
    with dpg.group(horizontal=True, parent=parent):
        dpg.add_text(f"{key}:", color=_LABEL_COLOR)
        dpg.add_input_text(default_value=str(val), readonly=True, width=-1)


def _render_usage(parent: str, usage: dict):
    """Render Anthropic usage dict as a compact token table."""
    if not usage:
        return
    dpg.add_text("usage:", color=_SUBHDR_COLOR, parent=parent)
    order = [
        "input_tokens",
        "cache_read_input_tokens",
        "cache_creation_input_tokens",
        "output_tokens",
    ]
    shown: set = set()
    for key in order:
        if key in usage:
            shown.add(key)
            _add_kv_row(parent, f"  {key.replace('_', ' ')}", usage[key], _NUM_COLOR)
    for key, val in usage.items():
        if key not in shown:
            _add_kv_row(parent, f"  {key}", val, _NUM_COLOR)


def _render_tool_calls_list(parent: str, tool_calls: list):
    """Render a list of tool_call dicts inline."""
    if not tool_calls:
        dpg.add_text("  (none)", color=_VALUE_COLOR, parent=parent)
        return
    for i, tc in enumerate(tool_calls):
        dpg.add_text(f"  call[{i}] {tc.get('name', '?')}", color=_KEY_COLOR, parent=parent)
        if "id" in tc:
            _add_kv_row(parent, "    id", tc["id"])
        args = tc.get("args") or tc.get("input") or {}
        if isinstance(args, dict):
            for ak, av in args.items():
                _add_text_field(parent, f"    {ak}", str(av))
        elif args:
            _add_text_field(parent, "    args", str(args))


# ---- kind-specific renderers ------------------------------------------------

def _render_payload_request(parent: str, payload: dict):
    _add_text_field(parent, "message", payload.get("message", ""))


def _render_payload_response(parent: str, payload: dict):
    _add_kv_row(parent, "round", payload.get("round", ""))
    _add_kv_row(parent, "stop_reason", payload.get("stop_reason", ""), (255, 200, 120))
    text = payload.get("text", "")
    if text:
        _add_text_field(parent, "text", text)
    dpg.add_text("tool_calls:", color=_LABEL_COLOR, parent=parent)
    _render_tool_calls_list(parent, payload.get("tool_calls", []))
    usage = payload.get("usage")
    if usage:
        _render_usage(parent, usage)


def _render_payload_tool_call(parent: str, payload: dict):
    _add_kv_row(parent, "name", payload.get("name", ""))
    if "id" in payload:
        _add_kv_row(parent, "id", payload["id"])
    # PowerShell tool uses 'script'; MCP file tools use 'args' dict
    if "script" in payload:
        _add_text_field(parent, "script", payload.get("script", ""))
    elif "args" in payload:
        args = payload["args"]
        if isinstance(args, dict):
            for ak, av in args.items():
                _add_text_field(parent, ak, str(av))
        else:
            _add_text_field(parent, "args", str(args))


def _render_payload_tool_result(parent: str, payload: dict):
    _add_kv_row(parent, "name", payload.get("name", ""))
    if "id" in payload:
        _add_kv_row(parent, "id", payload["id"])
    _add_text_field(parent, "output", payload.get("output", ""))


def _render_payload_tool_result_send(parent: str, payload: dict):
    for i, r in enumerate(payload.get("results", [])):
        dpg.add_text(f"result[{i}]", color=_KEY_COLOR, parent=parent)
        _add_kv_row(parent, "  tool_use_id", r.get("tool_use_id", ""))
        _add_text_field(parent, "  content", str(r.get("content", "")))


def _render_payload_generic(parent: str, payload: dict):
    """Fallback: render any unknown payload kind as labelled fields."""
    import json
    for key, val in payload.items():
        if isinstance(val, (dict, list)):
            val_str = json.dumps(val, ensure_ascii=False, indent=2)
        else:
            val_str = str(val)
        if key in _HEAVY_KEYS:
            _add_text_field(parent, key, val_str)
        else:
            _add_kv_row(parent, key, val_str)


_KIND_RENDERERS = {
    "request":          _render_payload_request,
    "response":         _render_payload_response,
    "tool_call":        _render_payload_tool_call,
    "tool_result":      _render_payload_tool_result,
    "tool_result_send": _render_payload_tool_result_send,
}


def _render_comms_entry(parent: str, entry: dict, idx: int):
    direction = entry["direction"]
    kind      = entry["kind"]
    ts        = entry["ts"]
    provider  = entry["provider"]
    model     = entry["model"]
    payload   = entry["payload"]

    dir_color  = _DIR_COLORS.get(direction, (220, 220, 220))
    kind_color = _KIND_COLORS.get(kind, (220, 220, 220))

    with dpg.group(horizontal=False, parent=parent):
        # Header row
        with dpg.group(horizontal=True):
            dpg.add_text(f"#{idx}", color=(160, 160, 160))
            dpg.add_text(ts,        color=(160, 160, 160))
            dpg.add_text(direction, color=dir_color)
            dpg.add_text(kind,      color=kind_color)
            dpg.add_text(f"{provider}/{model}", color=(180, 180, 180))

        # Payload - use rich renderer if available, else generic fallback
        renderer = _KIND_RENDERERS.get(kind, _render_payload_generic)
        renderer(parent, payload)

        dpg.add_separator()


class ConfirmDialog:
    """
    Modal confirmation window for a proposed PowerShell script.
    Background thread calls wait(), which blocks on a threading.Event.
    Main render loop detects _pending_dialog and calls show() on the next frame.
    User clicks Approve or Reject, which sets the event and unblocks the thread.
    """

    _next_id = 0

    def __init__(self, script: str, base_dir: str):
        ConfirmDialog._next_id += 1
        self._uid = ConfirmDialog._next_id
        self._tag = f"confirm_dlg_{self._uid}"
        # Cast to str to ensure DPG doesn't crash on None or weird objects
        self._script = str(script) if script is not None else ""
        self._base_dir = str(base_dir) if base_dir is not None else ""
        self._event = threading.Event()
        self._approved = False

    def show(self):
        """Called from main thread only. Wrapped in try/except to prevent thread lockups."""
        try:
            w, h = 700, 480
            vp_w = dpg.get_viewport_width()
            vp_h = dpg.get_viewport_height()
            px = max(0, (vp_w - w) // 2)
            py = max(0, (vp_h - h) // 2)

            with dpg.window(
                label=f"Approve PowerShell Command #{self._uid}",
                tag=self._tag,
                modal=True,
                no_close=True,
                pos=(px, py),
                width=w,
                height=h,
            ):
                dpg.add_text("The AI wants to run the following PowerShell script:")
                dpg.add_text(f"base_dir: {self._base_dir}", color=(200, 200, 100))
                dpg.add_separator()
                with dpg.group(horizontal=True):
                    dpg.add_text("Script:")
                    dpg.add_button(
                        label="[+ Maximize]",
                        user_data=self._script,
                        callback=lambda s, a, u: _show_text_viewer("Confirm Script", u)
                    )
                dpg.add_input_text(
                    tag=f"{self._tag}_script",
                    default_value=self._script,
                    multiline=True,
                    width=-1,
                    height=-72,
                    readonly=False,
                )
                dpg.add_separator()
                with dpg.group(horizontal=True):
                    dpg.add_button(label="Approve & Run", callback=self._cb_approve)
                    dpg.add_button(label="Reject", callback=self._cb_reject)
            
            dpg.focus_item(self._tag)
        except Exception as e:
            print(f"ERROR rendering ConfirmDialog: {e}")
            self._approved = False
            self._event.set()

    def _cb_approve(self):
        try:
            self._script = dpg.get_value(f"{self._tag}_script")
        except Exception:
            pass
        self._approved = True
        self._event.set()
        try:
            dpg.delete_item(self._tag)
        except Exception:
            pass

    def _cb_reject(self):
        self._approved = False
        self._event.set()
        try:
            dpg.delete_item(self._tag)
        except Exception:
            pass

    def wait(self) -> tuple[bool, str]:
        """Called from background thread. Blocks until user acts."""
        self._event.wait()
        return self._approved, self._script


DISC_ROLES = ["User", "AI", "Vendor API", "System"]


def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
    """
    Convert the raw TOML string array into a flat list of {role, content, collapsed, ts} dicts.
    Supports both legacy format (no timestamps) and new format (@timestamp prefix).
    """
    known = roles if roles is not None else DISC_ROLES
    entries: list[dict] = []
    for raw in history:
        entry = project_manager.str_to_entry(raw, known)
        entries.append(entry)
    return entries


class App:
    def __init__(self):
        self.config = load_config()
        # Controls whether API hooks are enabled, based on CLI arg or env var
        self.test_hooks_enabled: bool = (
            '--enable-test-hooks' in sys.argv or 
            os.environ.get('SLOP_TEST_HOOKS') == '1')
        # The API hook server instance
        self.hook_server: api_hooks.HookServer = api_hooks.HookServer(self)

        # ---- global settings from config.toml ----
        ai_cfg = self.config.get("ai", {})
        self.current_provider: str = ai_cfg.get("provider", "gemini")
        self.current_model: str = ai_cfg.get("model", "gemini-2.5-flash")
        self.temperature: float = ai_cfg.get("temperature", 0.0)
        self.max_tokens: int = ai_cfg.get("max_tokens", 8192)
        self.history_trunc_limit: int = ai_cfg.get("history_trunc_limit", 8000)
        self.available_models: list[str] = []

        # ---- project management ----
        projects_cfg = self.config.get("projects", {})
        self.project_paths: list[str] = list(projects_cfg.get("paths", []))
        self.active_project_path: str = projects_cfg.get("active", "")

        # The loaded project dict (from the active .toml file)
        self.project: dict = {}
        # The active discussion name within the project
        self.active_discussion: str = "main"

        # Load the active project, or migrate from legacy config
        self._load_active_project()

        # ---- project-derived state ----
        self.files: list[str] = list(self.project.get("files", {}).get("paths", []))
        self.screenshots: list[str] = list(self.project.get("screenshots", {}).get("paths", []))

        disc_sec = self.project.get("discussion", {})
        self.disc_roles: list[str] = list(disc_sec.get("roles", list(DISC_ROLES)))
        self.active_discussion = disc_sec.get("active", "main")

        disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
        history_strings = disc_data.get("history", [])
        self.disc_entries: list[dict] = _parse_history_entries(history_strings, self.disc_roles)

        self.ai_status = "idle"
        self.ai_response = ""
        self.last_md = ""
        self.last_md_path: Path | None = None
        self.last_file_items: list = []
        self.send_thread: threading.Thread | None = None
        self.models_thread: threading.Thread | None = None
        self.window_info = {
            "Context Hub": "win_context_hub",
            "AI Settings Hub": "win_ai_settings_hub",
            "Discussion Hub": "win_discussion_hub",
            "Operations Hub": "win_operations_hub",
            "Diagnostics": "win_diagnostics",
            "Theme": "win_theme",
            "Last Script Output": "win_script_output",
            "Text Viewer": "win_text_viewer",
        }


        self._pending_dialog: ConfirmDialog | None = None
        self._pending_dialog_lock = threading.Lock()

        self._tool_log: list[tuple[str, str]] = []
        self._last_script: str = ""
        self._last_output: str = ""

        # Comms log entries queued from background thread for main-thread rendering
        self._pending_comms: list[dict] = []
        self._pending_comms_lock = threading.Lock()
        self._comms_entry_count = 0
        
        # Auto-history queues
        self._pending_history_adds: list[dict] = []
        self._pending_history_adds_lock = threading.Lock()
        
        # API GUI Hooks Queue
        # Tasks (e.g., set_value, click) to be executed on the main DPG thread
        self._pending_gui_tasks: list[dict] = []
        # Lock for _pending_gui_tasks to ensure thread safety
        self._pending_gui_tasks_lock = threading.Lock()
        
        # Blink state
        self._trigger_blink = False
        self._is_blinking = False
        self._blink_start_time = 0.0

        # Script Blink State
        self._trigger_script_blink = False
        self._is_script_blinking = False
        self._script_blink_start_time = 0.0
        
        self.is_viewing_prior_session = False

        # Subscribe to API lifecycle events
        ai_client.events.on("request_start", self._on_api_event)
        ai_client.events.on("response_received", self._on_api_event)
        ai_client.events.on("tool_execution", self._on_api_event)

        self.perf_monitor = PerformanceMonitor()
        self.perf_history = {
            "frame_time": [0.0] * 100,
            "fps": [0.0] * 100,
            "cpu": [0.0] * 100,
            "input_lag": [0.0] * 100
        }

        self.session_usage = {
            "input_tokens": 0,
            "output_tokens": 0,
            "cache_read_input_tokens": 0,
            "cache_creation_input_tokens": 0
        }

        label = self.project.get("project", {}).get("name", "")
        session_logger.open_session(label=label)
        ai_client.set_provider(self.current_provider, self.current_model)
        ai_client.confirm_and_run_callback = self._confirm_and_run
        ai_client.comms_log_callback = self._on_comms_entry
        ai_client.tool_log_callback = self._on_tool_log
        mcp_client.perf_monitor_callback = self.perf_monitor.get_metrics
        self.perf_monitor.alert_callback = self._on_performance_alert
        self._last_diag_update_time = 0
        self._last_perf_update_time = 0
        self._last_bleed_update_time = 0
        self._last_script_alpha = -1
        self._last_resp_alpha = -1
        self._recalculate_session_usage()

    # ---------------------------------------------------------------- project loading

    def _load_active_project(self):
        """
        Load the active project .toml.  If no project paths configured or
        active path is missing, attempt migration from legacy config.toml.
        """
        # Try to load from the active path
        if self.active_project_path and Path(self.active_project_path).exists():
            try:
                self.project = project_manager.load_project(self.active_project_path)
                return
            except Exception as e:
                print(f"Failed to load project {self.active_project_path}: {e}")

        # Try first available project path
        for pp in self.project_paths:
            if Path(pp).exists():
                try:
                    self.project = project_manager.load_project(pp)
                    self.active_project_path = pp
                    return
                except Exception:
                    continue

        # No valid project file found - migrate from legacy config.toml
        self.project = project_manager.migrate_from_legacy_config(self.config)
        name = self.project.get("project", {}).get("name", "project")
        fallback_path = f"{name}.toml"
        project_manager.save_project(self.project, fallback_path)
        self.active_project_path = fallback_path
        if fallback_path not in self.project_paths:
            self.project_paths.append(fallback_path)

    def _switch_project(self, path: str):
        """Switch to a different project .toml file."""
        if not Path(path).exists():
            self._update_status(f"project file not found: {path}")
            return

        # Save current project first
        self._flush_to_project()
        self._save_active_project()

        # Load the new one
        try:
            self.project = project_manager.load_project(path)
            self.active_project_path = path
        except Exception as e:
            self._update_status(f"failed to load project: {e}")
            return

        # Refresh all project-derived state
        self._refresh_from_project()

        # Reset AI session since context changed
        ai_client.reset_session()
        self.cb_clear_tool_log()
        self.cb_clear_comms()
        self._update_response("")
        self._update_status(f"switched to: {Path(path).stem}")

    def _refresh_from_project(self):
        """Reload all GUI state from self.project after a project switch or discussion switch."""
        self.files = list(self.project.get("files", {}).get("paths", []))
        self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))

        disc_sec = self.project.get("discussion", {})
        self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES)))
        self.active_discussion = disc_sec.get("active", "main")

        disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
        history_strings = disc_data.get("history", [])
        self.disc_entries = _parse_history_entries(history_strings, self.disc_roles)

        # Update all GUI widgets
        self._refresh_project_widgets()
        self._rebuild_files_list()
        self._rebuild_shots_list()
        self._rebuild_disc_list()
        self._rebuild_disc_roles_list()
        self._rebuild_discussion_selector()

    def _refresh_project_widgets(self):
        """Push project-level values into the GUI widgets."""
        proj = self.project
        if dpg.does_item_exist("output_dir"):
            dpg.set_value("output_dir", proj.get("output", {}).get("output_dir", "./md_gen"))
        if dpg.does_item_exist("files_base_dir"):
            dpg.set_value("files_base_dir", proj.get("files", {}).get("base_dir", "."))
        if dpg.does_item_exist("shots_base_dir"):
            dpg.set_value("shots_base_dir", proj.get("screenshots", {}).get("base_dir", "."))
        if dpg.does_item_exist("project_name_text"):
            name = proj.get("project", {}).get("name", Path(self.active_project_path).stem)
            dpg.set_value("project_name_text", f"Active: {name}")
        if dpg.does_item_exist("project_git_dir"):
            dpg.set_value("project_git_dir", proj.get("project", {}).get("git_dir", ""))
        if dpg.does_item_exist("project_system_prompt"):
            dpg.set_value("project_system_prompt", proj.get("project", {}).get("system_prompt", ""))
        if dpg.does_item_exist("project_main_context"):
            dpg.set_value("project_main_context", proj.get("project", {}).get("main_context", ""))
        if dpg.does_item_exist("auto_add_history"):
            dpg.set_value("auto_add_history", proj.get("discussion", {}).get("auto_add", False))
        if dpg.does_item_exist("project_word_wrap"):
            dpg.set_value("project_word_wrap", proj.get("project", {}).get("word_wrap", True))
        
        agent_tools = proj.get("agent", {}).get("tools", {})
        for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
            tag = f"tool_toggle_{t_name}"
            if dpg.does_item_exist(tag):
                dpg.set_value(tag, agent_tools.get(t_name, True))

        self.cb_word_wrap_toggled(app_data=proj.get("project", {}).get("word_wrap", True))

    def _save_active_project(self):
        """Write self.project to the active project .toml file."""
        if self.active_project_path:
            try:
                project_manager.save_project(self.project, self.active_project_path)
            except Exception as e:
                self._update_status(f"save error: {e}")

    # ---------------------------------------------------------------- discussion management

    def _get_discussion_names(self) -> list[str]:
        """Return sorted list of discussion names in the active project."""
        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        return sorted(discussions.keys())

    def _switch_discussion(self, name: str):
        """Save current discussion entries, then switch to a different one."""
        # Save current entries into project
        self._flush_disc_entries_to_project()

        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        if name not in discussions:
            self._update_status(f"discussion not found: {name}")
            return

        self.active_discussion = name
        disc_sec["active"] = name

        disc_data = discussions[name]
        history_strings = disc_data.get("history", [])
        self.disc_entries = _parse_history_entries(history_strings, self.disc_roles)

        self._rebuild_disc_list()
        self._rebuild_discussion_selector()
        self._update_status(f"discussion: {name}")

    def _flush_disc_entries_to_project(self):
        """Serialize current disc_entries back into the active discussion in self.project."""
        # Pull latest content from widgets
        for i, entry in enumerate(self.disc_entries):
            tag = f"disc_content_{i}"
            if dpg.does_item_exist(tag):
                entry["content"] = dpg.get_value(tag)

        history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]

        disc_sec = self.project.setdefault("discussion", {})
        discussions = disc_sec.setdefault("discussions", {})
        disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
        disc_data["history"] = history_strings
        disc_data["last_updated"] = project_manager.now_ts()

    def _create_discussion(self, name: str):
        """Create a new empty discussion in the active project."""
        disc_sec = self.project.setdefault("discussion", {})
        discussions = disc_sec.setdefault("discussions", {})
        if name in discussions:
            self._update_status(f"discussion '{name}' already exists")
            return
        discussions[name] = project_manager.default_discussion()
        self._switch_discussion(name)

    def _rename_discussion(self, old_name: str, new_name: str):
        """Rename a discussion."""
        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        if old_name not in discussions:
            return
        if new_name in discussions:
            self._update_status(f"discussion '{new_name}' already exists")
            return
        discussions[new_name] = discussions.pop(old_name)
        if self.active_discussion == old_name:
            self.active_discussion = new_name
            disc_sec["active"] = new_name
        self._rebuild_discussion_selector()

    def _delete_discussion(self, name: str):
        """Delete a discussion. Cannot delete the last one."""
        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        if len(discussions) <= 1:
            self._update_status("cannot delete the last discussion")
            return
        if name not in discussions:
            return
        del discussions[name]
        if self.active_discussion == name:
            # Switch to the first remaining discussion
            remaining = sorted(discussions.keys())
            self._switch_discussion(remaining[0])
        else:
            self._rebuild_discussion_selector()

    def _update_discussion_git_commit(self):
        """Update the git commit hash on the active discussion."""
        git_dir = self.project.get("project", {}).get("git_dir", "")
        if not git_dir:
            git_dir = dpg.get_value("project_git_dir") if dpg.does_item_exist("project_git_dir") else ""
        if not git_dir:
            self._update_status("no git_dir configured")
            return
        commit = project_manager.get_git_commit(git_dir)
        if not commit:
            self._update_status("could not read git commit")
            return
        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        disc_data = discussions.get(self.active_discussion, {})
        disc_data["git_commit"] = commit
        disc_data["last_updated"] = project_manager.now_ts()
        self._rebuild_discussion_selector()
        self._update_status(f"commit: {commit[:12]}")

    def _queue_history_add(self, role: str, content: str):
        """Safely queue a new history entry from a background thread."""
        with self._pending_history_adds_lock:
            self._pending_history_adds.append({
                "role": role,
                "content": content,
                "collapsed": False,
                "ts": project_manager.now_ts()
            })

    # ---------------------------------------------------------------- comms log

    def _on_comms_entry(self, entry: dict):
        """Called from background thread; queue for main thread."""
        session_logger.log_comms(entry)
        with self._pending_comms_lock:
            self._pending_comms.append(entry)

    def _on_tool_log(self, script: str, result: str):
        """Called from background thread when a tool call completes."""
        session_logger.log_tool_call(script, result, None)

    def _on_performance_alert(self, message: str):
        """Called by PerformanceMonitor when a threshold is exceeded."""
        alert_text = f"[PERFORMANCE ALERT] {message}. Please consider optimizing recent changes or reducing load."
        # Inject into history as a 'System' message or similar
        with self._pending_history_adds_lock:
            self._pending_history_adds.append({
                "role": "System",
                "content": alert_text,
                "ts": project_manager.now_ts()
            })

    def _recalculate_session_usage(self):
        """Aggregates usage across the session from comms log."""
        usage = {
            "input_tokens": 0,
            "output_tokens": 0,
            "cache_read_input_tokens": 0,
            "cache_creation_input_tokens": 0
        }
        for entry in ai_client.get_comms_log():
            if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
                u = entry["payload"]["usage"]
                for k in usage.keys():
                    usage[k] += u.get(k, 0) or 0
        self.session_usage = usage

    def _flush_pending_comms(self):
        """Called every frame from the main render loop."""
        with self._pending_comms_lock:
            entries = self._pending_comms[:]
            self._pending_comms.clear()
        for entry in entries:
            self._comms_entry_count += 1
            self._append_comms_entry(entry, self._comms_entry_count)
        if entries:
            self._recalculate_session_usage()
            self._update_token_usage()

    def _update_token_usage(self):
        if not dpg.does_item_exist("ai_token_usage"):
            return
        usage = self.session_usage
        total = usage["input_tokens"] + usage["output_tokens"]
        dpg.set_value("ai_token_usage", f"Tokens: {total} (In: {usage['input_tokens']} Out: {usage['output_tokens']})")

    def _on_api_event(self, *args, **kwargs):
        """Callback for ai_client events. Queues a telemetry refresh on the main thread."""
        payload = kwargs.get("payload", {})
        with self._pending_gui_tasks_lock:
            self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})

    def _refresh_api_metrics(self, payload: dict = None):
        """Updates the token budget and cache stats visualizers."""
        payload = payload or {}
        self._last_bleed_update_time = time.time()
        
        # History bleed
        stats = ai_client.get_history_bleed_stats()
        if dpg.does_item_exist("token_budget_bar"):
            percentage = stats.get("percentage", 0.0)
            dpg.set_value("token_budget_bar", percentage / 100.0 if percentage else 0.0)
        if dpg.does_item_exist("token_budget_label"):
            current = stats.get("current", 0)
            limit = stats.get("limit", 0)
            dpg.set_value("token_budget_label", f"{current:,} / {limit:,}")

        # Gemini cache - Use payload data to avoid blocking the main thread with network calls
        if dpg.does_item_exist("gemini_cache_label"):
            cache_stats = payload.get("cache_stats")
            if cache_stats:
                count = cache_stats.get("cache_count", 0)
                size_bytes = cache_stats.get("total_size_bytes", 0)
                size_kb = size_bytes / 1024.0
                text = f"Gemini Caches: {count} ({size_kb:.1f} KB)"
                dpg.set_value("gemini_cache_label", text)
                dpg.configure_item("gemini_cache_label", show=True)
            elif self.current_provider != "gemini":
                dpg.configure_item("gemini_cache_label", show=False)
            # Note: We don't hide it if no stats are in payload, 
            # to avoid flickering during tool/chunk events that don't include stats.

    def _update_performance_diagnostics(self):
        """Updates performance diagnostics displays (throttled)."""
        now = time.time()

        # Update Diagnostics panel (throttled for smoothness)
        if now - self._last_perf_update_time > 0.5:
            self._last_perf_update_time = now
            if dpg.is_item_shown("win_diagnostics"):
                metrics = self.perf_monitor.get_metrics()
                
                # Update history
                self.perf_history["frame_time"].append(metrics['last_frame_time_ms'])
                self.perf_history["fps"].append(metrics['fps'])
                self.perf_history["cpu"].append(metrics['cpu_percent'])
                self.perf_history["input_lag"].append(metrics['input_lag_ms'])
                
                for k in self.perf_history:
                    if len(self.perf_history[k]) > 100:
                        self.perf_history[k].pop(0)

                # Update labels
                dpg.set_value("perf_fps_text", f"{metrics['fps']:.1f}")
                dpg.set_value("perf_frame_text", f"{metrics['last_frame_time_ms']:.1f}ms")
                dpg.set_value("perf_cpu_text", f"{metrics['cpu_percent']:.1f}%")
                dpg.set_value("perf_lag_text", f"{metrics['input_lag_ms']:.1f}ms")
                
                # Update plots
                if dpg.does_item_exist("perf_frame_plot"):
                    dpg.set_value("perf_frame_plot", [list(range(100)), self.perf_history["frame_time"]])
                if dpg.does_item_exist("perf_cpu_plot"):
                    dpg.set_value("perf_cpu_plot", [list(range(100)), self.perf_history["cpu"]])

    def _append_comms_entry(self, entry: dict, idx: int):
        if not dpg.does_item_exist("comms_scroll"):
            return
        _render_comms_entry("comms_scroll", entry, idx)

    def _rebuild_comms_log(self):
        """Full redraw from ai_client.get_comms_log() - used after clear/reset."""
        if not dpg.does_item_exist("comms_scroll"):
            return
        dpg.delete_item("comms_scroll", children_only=True)
        self._comms_entry_count = 0
        for entry in ai_client.get_comms_log():
            self._comms_entry_count += 1
            _render_comms_entry("comms_scroll", entry, self._comms_entry_count)

    # ---------------------------------------------------------------- tool execution

    def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
        dialog = ConfirmDialog(script, base_dir)

        with self._pending_dialog_lock:
            self._pending_dialog = dialog

        approved, final_script = dialog.wait()

        if not approved:
            self._append_tool_log(final_script, "REJECTED by user")
            return None

        self._update_status("running powershell...")
        output = shell_runner.run_powershell(final_script, base_dir)
        self._append_tool_log(final_script, output)
        self._update_status("powershell done, awaiting AI...")
        return output

    def _append_tool_log(self, script: str, result: str):
        self._last_script = script
        self._last_output = result
        self._tool_log.append((script, result))
        self._rebuild_tool_log()
        
        if dpg.does_item_exist("last_script_text"):
            dpg.set_value("last_script_text", script)
        if dpg.does_item_exist("last_script_text_wrap"):
            dpg.set_value("last_script_text_wrap", script)
            
        if dpg.does_item_exist("last_script_output"):
            dpg.set_value("last_script_output", result)
        if dpg.does_item_exist("last_script_output_wrap"):
            dpg.set_value("last_script_output_wrap", result)
            
        self._trigger_script_blink = True

    def _rebuild_tool_log(self):
        if not dpg.does_item_exist("tool_log_scroll"):
            return
        wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
        dpg.delete_item("tool_log_scroll", children_only=True)
        for i, (script, result) in enumerate(self._tool_log, 1):
            with dpg.group(parent="tool_log_scroll"):
                first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)"
                with dpg.group(horizontal=True):
                    dpg.add_text(f"Call #{i}: {first_line}", color=(140, 200, 255))
                    dpg.add_button(
                        label="[+ Script]", 
                        user_data=script,
                        callback=lambda s, a, u: _show_text_viewer("Call Script", u)
                    )
                    dpg.add_button(
                        label="[+ Output]", 
                        user_data=result,
                        callback=lambda s, a, u: _show_text_viewer("Call Output", u)
                    )
                if wrap:
                    with dpg.child_window(height=72, border=True):
                        dpg.add_text(result, wrap=0)
                else:
                    dpg.add_input_text(
                        default_value=result,
                        multiline=True,
                        readonly=True,
                        width=-1,
                        height=72,
                    )
                dpg.add_separator()

    # ---------------------------------------------------------------- helpers

    def _flush_to_project(self):
        """Pull all widget values into self.project (the active project dict)."""
        proj = self.project

        # Output
        proj.setdefault("output", {})
        if dpg.does_item_exist("output_dir"):
            proj["output"]["output_dir"] = dpg.get_value("output_dir")

        # Files
        proj.setdefault("files", {})
        if dpg.does_item_exist("files_base_dir"):
            proj["files"]["base_dir"] = dpg.get_value("files_base_dir")
        proj["files"]["paths"] = self.files

        # Screenshots
        proj.setdefault("screenshots", {})
        if dpg.does_item_exist("shots_base_dir"):
            proj["screenshots"]["base_dir"] = dpg.get_value("shots_base_dir")
        proj["screenshots"]["paths"] = self.screenshots

        # Project metadata
        proj.setdefault("project", {})
        if dpg.does_item_exist("project_git_dir"):
            proj["project"]["git_dir"] = dpg.get_value("project_git_dir")
        if dpg.does_item_exist("project_system_prompt"):
            proj["project"]["system_prompt"] = dpg.get_value("project_system_prompt")
        if dpg.does_item_exist("project_main_context"):
            proj["project"]["main_context"] = dpg.get_value("project_main_context")
        if dpg.does_item_exist("project_word_wrap"):
            proj["project"]["word_wrap"] = dpg.get_value("project_word_wrap")

        # Agent tools
        proj.setdefault("agent", {}).setdefault("tools", {})
        for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
            tag = f"tool_toggle_{t_name}"
            if dpg.does_item_exist(tag):
                proj["agent"]["tools"][t_name] = dpg.get_value(tag)

        # Discussion
        self._flush_disc_entries_to_project()
        disc_sec = proj.setdefault("discussion", {})
        disc_sec["roles"] = self.disc_roles
        disc_sec["active"] = self.active_discussion
        if dpg.does_item_exist("auto_add_history"):
            disc_sec["auto_add"] = dpg.get_value("auto_add_history")

    def _flush_to_config(self):
        """Pull global settings into self.config (config.toml)."""
        self.config["ai"] = {
            "provider": self.current_provider,
            "model":    self.current_model,
            "temperature": dpg.get_value("ai_temperature") if dpg.does_item_exist("ai_temperature") else self.temperature,
            "max_tokens": dpg.get_value("ai_max_tokens") if dpg.does_item_exist("ai_max_tokens") else self.max_tokens,
            "history_trunc_limit": dpg.get_value("ai_history_trunc") if dpg.does_item_exist("ai_history_trunc") else self.history_trunc_limit,
        }
        if dpg.does_item_exist("global_system_prompt"):
            self.config["ai"]["system_prompt"] = dpg.get_value("global_system_prompt")
        self.config["projects"] = {
            "paths":  self.project_paths,
            "active": self.active_project_path,
        }
        theme.save_to_config(self.config)

    def _do_generate(self) -> tuple[str, Path, list]:
        self._flush_to_project()
        self._save_active_project()
        self._flush_to_config()
        save_config(self.config)
        flat = project_manager.flat_config(self.project, self.active_discussion)
        return aggregate.run(flat)

    def _update_status(self, status: str):
        self.ai_status = status
        if dpg.does_item_exist("ai_status"):
            dpg.set_value("ai_status", f"Status: {status}")
        
        if dpg.does_item_exist("thinking_indicator"):
            is_thinking = status in ["sending...", "running powershell..."]
            dpg.configure_item("thinking_indicator", show=is_thinking)
        
        if dpg.does_item_exist("operations_live_indicator"):
            is_running = status in ["running powershell...", "fetching url...", "searching web..."]
            dpg.configure_item("operations_live_indicator", show=is_running)

    def _update_response(self, text: str):
        self.ai_response = text
        if dpg.does_item_exist("ai_response"):
            dpg.set_value("ai_response", text)
        if dpg.does_item_exist("ai_response_wrap"):
            dpg.set_value("ai_response_wrap", text)

    def _rebuild_files_list(self):
        if not dpg.does_item_exist("files_scroll"):
            return
        dpg.delete_item("files_scroll", children_only=True)
        for i, f in enumerate(self.files):
            with dpg.group(horizontal=True, parent="files_scroll"):
                dpg.add_button(
                    label="x", width=24, callback=self._make_remove_file_cb(i)
                )
                dpg.add_text(f)

    def _rebuild_shots_list(self):
        if not dpg.does_item_exist("shots_scroll"):
            return
        dpg.delete_item("shots_scroll", children_only=True)
        for i, s in enumerate(self.screenshots):
            with dpg.group(horizontal=True, parent="shots_scroll"):
                dpg.add_button(
                    label="x", width=24, callback=self._make_remove_shot_cb(i)
                )
                dpg.add_text(s)

    def _rebuild_models_list(self):
        if not dpg.does_item_exist("model_listbox"):
            return
        dpg.configure_item("model_listbox", items=self.available_models)
        if self.current_model in self.available_models:
            dpg.set_value("model_listbox", self.current_model)
        elif self.available_models:
            self.current_model = self.available_models[0]
            dpg.set_value("model_listbox", self.current_model)
            ai_client.set_provider(self.current_provider, self.current_model)

    def _rebuild_projects_list(self):
        if not dpg.does_item_exist("projects_scroll"):
            return
        dpg.delete_item("projects_scroll", children_only=True)
        for i, pp in enumerate(self.project_paths):
            is_active = (pp == self.active_project_path)
            with dpg.group(horizontal=True, parent="projects_scroll"):
                dpg.add_button(
                    label="x", width=24, callback=self._make_remove_project_cb(i)
                )
                name_color = (140, 255, 160) if is_active else (200, 200, 200)
                marker = " *" if is_active else ""
                dpg.add_button(
                    label=f"{Path(pp).stem}{marker}",
                    callback=self._make_switch_project_cb(pp),
                )
                dpg.add_text(pp, color=(140, 140, 140))

    def _rebuild_discussion_selector(self):
        """Rebuild the discussion selector UI: listbox + metadata for active discussion."""
        if not dpg.does_item_exist("disc_selector_group"):
            return
        for tag in ["disc_listbox", "disc_new_name_input", "btn_disc_create", "btn_disc_rename", "btn_disc_delete"]:
            if dpg.does_item_exist(tag):
                try: dpg.delete_item(tag)
                except: pass
        dpg.delete_item("disc_selector_group", children_only=True)

        names = self._get_discussion_names()

        dpg.add_listbox(
            tag="disc_listbox",
            items=names,
            default_value=self.active_discussion,
            width=-1,
            num_items=min(len(names), 5),
            callback=self.cb_disc_switch,
            parent="disc_selector_group",
        )

        # Show metadata for the active discussion
        disc_sec = self.project.get("discussion", {})
        disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
        git_commit = disc_data.get("git_commit", "")
        last_updated = disc_data.get("last_updated", "")

        with dpg.group(horizontal=False, parent="disc_selector_group"):
            with dpg.group(horizontal=True):
                dpg.add_text("commit:", color=(160, 160, 160))
                dpg.add_text(
                    git_commit[:12] if git_commit else "(none)",
                    color=(180, 255, 180) if git_commit else (120, 120, 120),
                )
                dpg.add_button(label="Update Commit", callback=self.cb_update_git_commit)
            with dpg.group(horizontal=True):
                dpg.add_text("updated:", color=(160, 160, 160))
                dpg.add_text(
                    last_updated if last_updated else "(never)",
                    color=(200, 200, 160),
                )

        with dpg.group(horizontal=True, parent="disc_selector_group"):
            dpg.add_input_text(
                tag="disc_new_name_input",
                hint="New discussion name",
                width=-180,
            )
            dpg.add_button(label="Create", tag="btn_disc_create", callback=self.cb_disc_create)
            dpg.add_button(label="Rename", tag="btn_disc_rename", callback=self.cb_disc_rename)
            dpg.add_button(label="Delete", tag="btn_disc_delete", callback=self.cb_disc_delete)

    def _make_remove_file_cb(self, idx: int):
        def cb():
            if idx < len(self.files):
                self.files.pop(idx)
                self._rebuild_files_list()
        return cb

    def _make_remove_shot_cb(self, idx: int):
        def cb():
            if idx < len(self.screenshots):
                self.screenshots.pop(idx)
                self._rebuild_shots_list()
        return cb

    def _make_remove_project_cb(self, idx: int):
        def cb():
            if idx < len(self.project_paths):
                removed = self.project_paths.pop(idx)
                if removed == self.active_project_path and self.project_paths:
                    self._switch_project(self.project_paths[0])
                self._rebuild_projects_list()
        return cb

    def _make_switch_project_cb(self, path: str):
        def cb():
            if path != self.active_project_path:
                self._switch_project(path)
                self._rebuild_projects_list()
        return cb

    def _fetch_models(self, provider: str):
        self._update_status("fetching models...")

        def do_fetch():
            try:
                models = ai_client.list_models(provider)
                self.available_models = models
                self._rebuild_models_list()
                self._update_status(f"models loaded: {len(models)}")
            except Exception as e:
                self._update_status(f"model fetch error: {e}")

        self.models_thread = threading.Thread(target=do_fetch, daemon=True)
        self.models_thread.start()

    # ---------------------------------------------------------------- callbacks

    def cb_word_wrap_toggled(self, sender=None, app_data=None):
        # This function is now also called by _refresh_project_widgets to set initial state
        if app_data is None:
            wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
        else:
            wrap = app_data
        
        # Persist the setting
        self.project.setdefault("project", {})["word_wrap"] = wrap

        # Toggle visibility of persistent wrapped/unwrapped container pairs
        persistent_panels = [
            "ai_response", "last_script_text", "last_script_output", "text_viewer_content"
        ]
        for name in persistent_panels:
            no_wrap_widget = name
            wrap_container = f"{name}_wrap_container"
            if dpg.does_item_exist(no_wrap_widget):
                dpg.configure_item(no_wrap_widget, show=not wrap)
            if dpg.does_item_exist(wrap_container):
                dpg.configure_item(wrap_container, show=wrap)

        # Re-render UI components with dynamic content that needs to change widget type
        self._rebuild_comms_log()
        self._rebuild_tool_log()

    def cb_browse_output(self):
        root = hide_tk_root()
        d = filedialog.askdirectory(title="Select Output Dir")
        root.destroy()
        if d:
            dpg.set_value("output_dir", d)

    def cb_save_config(self):
        self._flush_to_project()
        self._save_active_project()
        self._flush_to_config()
        save_config(self.config)
        self._update_status("config saved")

    def cb_browse_files_base(self):
        root = hide_tk_root()
        d = filedialog.askdirectory(title="Select Files Base Dir")
        root.destroy()
        if d:
            dpg.set_value("files_base_dir", d)

    def cb_add_files(self):
        root = hide_tk_root()
        paths = filedialog.askopenfilenames(title="Select Files")
        root.destroy()
        for p in paths:
            if p not in self.files:
                self.files.append(p)
        self._rebuild_files_list()

    def cb_add_wildcard(self):
        root = hide_tk_root()
        d = filedialog.askdirectory(title="Select Dir for Wildcard")
        root.destroy()
        if d:
            self.files.append(str(Path(d) / "**" / "*"))
        self._rebuild_files_list()

    def cb_browse_shots_base(self):
        root = hide_tk_root()
        d = filedialog.askdirectory(title="Select Screenshots Base Dir")
        root.destroy()
        if d:
            dpg.set_value("shots_base_dir", d)

    def cb_add_shots(self):
        root = hide_tk_root()
        paths = filedialog.askopenfilenames(
            title="Select Screenshots",
            filetypes=[
                ("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"),
                ("All", "*.*"),
            ],
        )
        root.destroy()
        for p in paths:
            if p not in self.screenshots:
                self.screenshots.append(p)
        self._rebuild_shots_list()

    def cb_md_only(self):
        try:
            md, path, _file_items = self._do_generate()
            self.last_md = md
            self.last_md_path = path
            self._update_status(f"md written: {path.name}")
        except Exception as e:
            self._update_status(f"error: {e}")

    def cb_load_prior_log(self):
        root = hide_tk_root()
        path = filedialog.askopenfilename(
            title="Load Session Log",
            initialdir="logs",
            filetypes=[("Log Files", "*.log"), ("JSONL Files", "*.jsonl"), ("All Files", "*.*")]
        )
        root.destroy()
        if not path:
            return
            
        try:
            import json
            entries = []
            with open(path, "r", encoding="utf-8") as f:
                for line in f:
                    if line.strip():
                        entries.append(json.loads(line))
            
            if not entries:
                return

            self.is_viewing_prior_session = True
            dpg.configure_item("prior_session_indicator", show=True)
            dpg.configure_item("exit_prior_btn", show=True)
            
            # Apply Tinted Mode Theme
            if not dpg.does_item_exist("prior_session_theme"):
                with dpg.theme(tag="prior_session_theme"):
                    with dpg.theme_component(dpg.mvAll):
                        # Tint everything slightly amber/sepia
                        dpg.add_theme_color(dpg.mvThemeCol_WindowBg, (40, 30, 20, 255))
                        dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (50, 40, 30, 255))
            
            for hub in ["win_context_hub", "win_ai_settings_hub", "win_discussion_hub", "win_operations_hub", "win_diagnostics"]:
                if dpg.does_item_exist(hub):
                    dpg.bind_item_theme(hub, "prior_session_theme")

            # Clear and render old entries
            dpg.delete_item("comms_scroll", children_only=True)
            for i, entry in enumerate(entries):
                _render_comms_entry("comms_scroll", entry, i + 1)
                
        except Exception as e:
            self._update_status(f"Load error: {e}")

    def cb_exit_prior_session(self):
        self.is_viewing_prior_session = False
        dpg.configure_item("prior_session_indicator", show=False)
        dpg.configure_item("exit_prior_btn", show=False)
        
        # Unbind theme
        for hub in ["win_context_hub", "win_ai_settings_hub", "win_discussion_hub", "win_operations_hub", "win_diagnostics"]:
            if dpg.does_item_exist(hub):
                dpg.bind_item_theme(hub, 0)
        
        # Restore current session comms
        self._rebuild_comms_log()

    def cb_reset_session(self):
        ai_client.reset_session()
        ai_client.clear_comms_log()
        self._tool_log.clear()
        self._rebuild_tool_log()
        self.disc_entries.clear()
        self._rebuild_disc_list()
        
        # Clear history in project dict too
        disc_sec = self.project.get("discussion", {})
        discussions = disc_sec.get("discussions", {})
        if self.active_discussion in discussions:
            discussions[self.active_discussion]["history"] = []

        with self._pending_comms_lock:
            self._pending_comms.clear()
        self._comms_entry_count = 0
        if dpg.does_item_exist("comms_scroll"):
            dpg.delete_item("comms_scroll", children_only=True)
        self._update_status("session reset")
        self._update_response("")

    def cb_generate_send(self):
        if self.send_thread and self.send_thread.is_alive():
            return
        try:
            md, path, file_items = self._do_generate()
            self.last_md = md
            self.last_md_path = path
            self.last_file_items = file_items
        except Exception as e:
            self._update_status(f"generate error: {e}")
            return

        self._update_status("sending...")
        user_msg = dpg.get_value("ai_input")
        base_dir = dpg.get_value("files_base_dir")

        global_sp = dpg.get_value("global_system_prompt") if dpg.does_item_exist("global_system_prompt") else ""
        project_sp = dpg.get_value("project_system_prompt") if dpg.does_item_exist("project_system_prompt") else ""
        combined_sp = []
        if global_sp: combined_sp.append(global_sp.strip())
        if project_sp: combined_sp.append(project_sp.strip())
        ai_client.set_custom_system_prompt("\n\n".join(combined_sp))
        ai_client.set_agent_tools(self.project.get("agent", {}).get("tools", {}))
        temp = dpg.get_value("ai_temperature") if dpg.does_item_exist("ai_temperature") else 0.0
        max_tok = dpg.get_value("ai_max_tokens") if dpg.does_item_exist("ai_max_tokens") else 8192
        trunc = dpg.get_value("ai_history_trunc") if dpg.does_item_exist("ai_history_trunc") else 8000
        ai_client.set_model_params(temp, max_tok, trunc)

        def do_send():
            auto_add = dpg.get_value("auto_add_history") if dpg.does_item_exist("auto_add_history") else False
            if auto_add:
                self._queue_history_add("User", user_msg)
            try:
                response = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
                self._update_response(response)
                self._update_status("done")
                self._trigger_blink = True
                if auto_add:
                    self._queue_history_add("AI", response)
            except ProviderError as e:
                resp = e.ui_message()
                self._update_response(resp)
                self._update_status("error")
                self._trigger_blink = True
                if auto_add:
                    self._queue_history_add("Vendor API", resp)
            except Exception as e:
                resp = f"ERROR: {e}"
                self._update_response(resp)
                self._update_status("error")
                self._trigger_blink = True
                if auto_add:
                    self._queue_history_add("System", resp)

        self.send_thread = threading.Thread(target=do_send, daemon=True)
        self.send_thread.start()

    def cb_provider_changed(self, sender, app_data):
        self.current_provider = app_data
        ai_client.reset_session()
        ai_client.set_provider(self.current_provider, self.current_model)
        self.available_models = []
        self._rebuild_models_list()
        self._fetch_models(self.current_provider)

    def cb_model_changed(self, sender, app_data):
        if app_data:
            self.current_model = app_data
            ai_client.reset_session()
            ai_client.set_provider(self.current_provider, self.current_model)
            self._update_status(f"model set: {self.current_model}")

    def cb_fetch_models(self):
        self._fetch_models(self.current_provider)

    def cb_clear_tool_log(self):
        self._tool_log.clear()
        self._rebuild_tool_log()

    def cb_clear_comms(self):
        ai_client.clear_comms_log()
        with self._pending_comms_lock:
            self._pending_comms.clear()
        self._comms_entry_count = 0
        self._update_token_usage()
        if dpg.does_item_exist("comms_scroll"):
            dpg.delete_item("comms_scroll", children_only=True)

    # ---- project callbacks ----

    def cb_add_project(self):
        root = hide_tk_root()
        p = filedialog.askopenfilename(
            title="Select Project .toml",
            filetypes=[("TOML", "*.toml"), ("All", "*.*")],
        )
        root.destroy()
        if p and p not in self.project_paths:
            self.project_paths.append(p)
            self._rebuild_projects_list()

    def cb_new_project(self):
        root = hide_tk_root()
        p = filedialog.asksaveasfilename(
            title="Create New Project .toml",
            defaultextension=".toml",
            filetypes=[("TOML", "*.toml"), ("All", "*.*")],
        )
        root.destroy()
        if not p:
            return
        name = Path(p).stem
        proj = project_manager.default_project(name)
        project_manager.save_project(proj, p)
        if p not in self.project_paths:
            self.project_paths.append(p)
        self._switch_project(p)
        self._rebuild_projects_list()
        self._update_status(f"created project: {name}")

    def _cb_new_project_automated(self, path):
        """Automated version of cb_new_project that doesn't show a dialog."""
        if not path:
            return
        name = Path(path).stem
        proj = project_manager.default_project(name)
        project_manager.save_project(proj, path)
        if path not in self.project_paths:
            self.project_paths.append(path)
        
        # Safely queue project switch and list rebuild for the main thread
        def main_thread_work():
            self._switch_project(path)
            self._rebuild_projects_list()
            self._update_status(f"created project: {name}")

        with self._pending_gui_tasks_lock:
            self._pending_gui_tasks.append({
                "action": "custom_callback",
                "callback": main_thread_work
            })

    def cb_browse_git_dir(self):
        root = hide_tk_root()
        d = filedialog.askdirectory(title="Select Git Directory")
        root.destroy()
        if d and dpg.does_item_exist("project_git_dir"):
            dpg.set_value("project_git_dir", d)

    def cb_browse_main_context(self):
        root = hide_tk_root()
        p = filedialog.askopenfilename(title="Select Main Context File")
        root.destroy()
        if p and dpg.does_item_exist("project_main_context"):
            dpg.set_value("project_main_context", p)

    # ---- discussion callbacks ----

    def cb_disc_switch(self, sender, app_data):
        if app_data and app_data != self.active_discussion:
            self._switch_discussion(app_data)

    def cb_disc_create(self):
        if not dpg.does_item_exist("disc_new_name_input"):
            return
        name = dpg.get_value("disc_new_name_input").strip()
        if not name:
            self._update_status("enter a discussion name")
            return
        self._create_discussion(name)
        dpg.set_value("disc_new_name_input", "")

    def cb_disc_rename(self):
        if not dpg.does_item_exist("disc_new_name_input"):
            return
        new_name = dpg.get_value("disc_new_name_input").strip()
        if not new_name:
            self._update_status("enter a new name")
            return
        self._rename_discussion(self.active_discussion, new_name)
        dpg.set_value("disc_new_name_input", "")

    def cb_disc_delete(self):
        self._delete_discussion(self.active_discussion)

    def cb_update_git_commit(self):
        self._update_discussion_git_commit()

    def cb_disc_save(self):
        self._flush_to_project()
        self._save_active_project()
        self._flush_to_config()
        save_config(self.config)
        self._update_status("discussion saved")

    def cb_disc_append_entry(self):
        default_role = self.disc_roles[0] if self.disc_roles else "User"
        self.disc_entries.append({
            "role": default_role,
            "content": "",
            "collapsed": False,
            "ts": project_manager.now_ts(),
        })
        self._rebuild_disc_list()

    def cb_disc_clear(self):
        self.disc_entries.clear()
        self._rebuild_disc_list()

    def cb_disc_truncate(self):
        pairs = dpg.get_value("disc_truncate_pairs") if dpg.does_item_exist("disc_truncate_pairs") else 2
        self.disc_entries = truncate_entries(self.disc_entries, pairs)
        self._rebuild_disc_list()
        self._update_status(f"history truncated to {pairs} pairs")

    def cb_disc_collapse_all(self):
        for i, entry in enumerate(self.disc_entries):
            tag = f"disc_content_{i}"
            if dpg.does_item_exist(tag):
                entry["content"] = dpg.get_value(tag)
            entry["collapsed"] = True
        self._rebuild_disc_list()

    def cb_disc_expand_all(self):
        for entry in self.disc_entries:
            entry["collapsed"] = False
        self._rebuild_disc_list()

    def cb_append_message_to_history(self):
        msg = dpg.get_value("ai_input")
        if msg:
            self.disc_entries.append({
                "role": "User",
                "content": msg,
                "collapsed": False,
                "ts": project_manager.now_ts(),
            })
            self._rebuild_disc_list()

    def cb_append_response_to_history(self):
        resp = self.ai_response
        if resp:
            self.disc_entries.append({
                "role": "AI",
                "content": resp,
                "collapsed": False,
                "ts": project_manager.now_ts(),
            })
            self._rebuild_disc_list()

    # ---- disc roles ----

    def _rebuild_disc_roles_list(self):
        if not dpg.does_item_exist("disc_roles_scroll"):
            return
        dpg.delete_item("disc_roles_scroll", children_only=True)
        for i, role in enumerate(self.disc_roles):
            with dpg.group(horizontal=True, parent="disc_roles_scroll"):
                dpg.add_button(
                    label="x", width=24,
                    callback=self._make_disc_remove_role_cb(i),
                )
                dpg.add_text(role)

    def _make_disc_remove_role_cb(self, idx: int):
        def cb():
            if idx < len(self.disc_roles):
                self.disc_roles.pop(idx)
                self._rebuild_disc_roles_list()
                self._rebuild_disc_list()
        return cb

    def cb_disc_add_role(self):
        if not dpg.does_item_exist("disc_new_role_input"):
            return
        name = dpg.get_value("disc_new_role_input").strip()
        if name and name not in self.disc_roles:
            self.disc_roles.append(name)
            dpg.set_value("disc_new_role_input", "")
            self._rebuild_disc_roles_list()
            self._rebuild_disc_list()

    # ---- disc entry list ----

    def _render_disc_entry(self, i: int, entry: dict):
        # Default to collapsed and read-mode if not specified
        if "collapsed" not in entry:
            entry["collapsed"] = True
        if "read_mode" not in entry:
            entry["read_mode"] = True
            
        collapsed = entry.get("collapsed", True)
        read_mode = entry.get("read_mode", True)
        ts_str = entry.get("ts", "")
        
        preview = entry["content"].replace("\n", " ")[:60]
        if len(entry["content"]) > 60:
            preview += "..."

        with dpg.group(parent="disc_scroll", tag=f"disc_entry_group_{i}"):
            with dpg.group(horizontal=True):
                dpg.add_button(
                    tag=f"disc_toggle_{i}",
                    label="+" if collapsed else "-",
                    width=24,
                    callback=self._make_disc_toggle_cb(i),
                )
                dpg.add_button(
                    label="[+ Max]",
                    user_data=i,
                    callback=lambda s, a, u: _show_text_viewer(f"Entry #{u+1}", self.disc_entries[u]["content"])
                )
                dpg.add_combo(
                    tag=f"disc_role_{i}",
                    items=self.disc_roles,
                    default_value=entry["role"],
                    width=120,
                    callback=self._make_disc_role_cb(i),
                )
                if not collapsed:
                    dpg.add_button(
                        label="[Edit]" if read_mode else "[Read]",
                        user_data=i,
                        callback=self._cb_toggle_read
                    )
                if ts_str:
                    dpg.add_text(ts_str, color=(120, 120, 100))
                if collapsed:
                    dpg.add_button(
                        label="Ins",
                        width=36,
                        callback=self._make_disc_insert_cb(i),
                    )
                    dpg.add_button(
                        label="Del",
                        width=36,
                        callback=self._make_disc_remove_cb(i),
                    )
                    dpg.add_text(preview, color=(160, 160, 150))
            
            with dpg.group(tag=f"disc_body_{i}", show=not collapsed):
                if read_mode:
                    # Use a read-only input_text instead of dpg.add_text to allow selection
                    dpg.add_input_text(
                        default_value=entry["content"],
                        multiline=True,
                        readonly=True,
                        width=-1,
                        height=150,
                    )
                else:
                    dpg.add_input_text(
                        tag=f"disc_content_{i}",
                        default_value=entry["content"],
                        multiline=True,
                        width=-1,
                        height=150,
                        callback=self._make_disc_content_cb(i),
                        on_enter=False,
                    )
            dpg.add_separator()

    def _cb_toggle_read(self, sender, app_data, user_data):
        idx = user_data
        # Save edit box content before switching to read mode
        tag = f"disc_content_{idx}"
        if dpg.does_item_exist(tag) and not self.disc_entries[idx].get("read_mode", False):
            self.disc_entries[idx]["content"] = dpg.get_value(tag)
        self.disc_entries[idx]["read_mode"] = not self.disc_entries[idx].get("read_mode", False)
        self._rebuild_disc_list()

    def _rebuild_disc_list(self):
        """Full rebuild of the discussion UI. Expensive! Use incrementally where possible."""
        if not dpg.does_item_exist("disc_scroll"):
            return
            
        dpg.delete_item("disc_scroll", children_only=True)
        for i, entry in enumerate(self.disc_entries):
            self._render_disc_entry(i, entry)

    def _make_disc_role_cb(self, idx: int):
        def cb(sender, app_data):
            if idx < len(self.disc_entries):
                self.disc_entries[idx]["role"] = app_data
        return cb

    def _make_disc_content_cb(self, idx: int):
        def cb(sender, app_data):
            if idx < len(self.disc_entries):
                self.disc_entries[idx]["content"] = app_data
        return cb

    def _make_disc_insert_cb(self, idx: int):
        def cb():
            self.disc_entries.insert(idx, {
                "role": "User",
                "content": "",
                "collapsed": False,
                "ts": project_manager.now_ts(),
            })
            self._rebuild_disc_list()
        return cb

    def _make_disc_remove_cb(self, idx: int):
        def cb():
            if idx < len(self.disc_entries):
                self.disc_entries.pop(idx)
                self._rebuild_disc_list()
        return cb

    def _make_disc_toggle_cb(self, idx: int):
        def cb():
            if idx < len(self.disc_entries):
                tag = f"disc_content_{idx}"
                if dpg.does_item_exist(tag):
                    self.disc_entries[idx]["content"] = dpg.get_value(tag)
                self.disc_entries[idx]["collapsed"] = not self.disc_entries[idx].get("collapsed", False)
                self._rebuild_disc_list()
        return cb

    # ------------------------------------------------------------ theme

    def cb_palette_changed(self, sender, app_data):
        theme.apply(app_data, self._read_colour_overrides())
        self._update_status(f"palette: {app_data}")

    def cb_apply_font(self):
        path = dpg.get_value("theme_font_path").strip()
        size = dpg.get_value("theme_font_size")
        theme.apply_font(path, size)
        self._update_status(f"font applied: {path or '(default)'} @{size}px")

    def cb_browse_font(self):
        root = hide_tk_root()
        p = filedialog.askopenfilename(
            title="Select Font",
            filetypes=[("TrueType / OpenType", "*.ttf *.otf"), ("All", "*.*")],
        )
        root.destroy()
        if p:
            dpg.set_value("theme_font_path", p)
            self.cb_apply_font()

    def cb_scale_changed(self, sender, app_data):
        theme.set_scale(round(app_data, 2))

    def _read_colour_overrides(self) -> dict:
        return {}

    # ------------------------------------------------------------ build ui

    def _build_theme_window(self):
        t_cfg = self.config.get("theme", {})
        cur_palette   = t_cfg.get("palette",   "DPG Default")
        cur_font_path = t_cfg.get("font_path", "")
        cur_font_size = float(t_cfg.get("font_size", 14.0))
        cur_scale     = float(t_cfg.get("scale",     1.0))

        with dpg.window(
            label="Theme",
            tag="win_theme",
            pos=(416, 516),
            width=400,
            height=280,
            no_close=False,
        ):
            dpg.add_text("Palette")
            dpg.add_combo(
                tag="theme_palette",
                items=theme.PALETTE_NAMES,
                default_value=cur_palette,
                width=-1,
                callback=self.cb_palette_changed,
            )
            dpg.add_separator()
            dpg.add_text("Font")
            with dpg.group(horizontal=True):
                dpg.add_input_text(
                    tag="theme_font_path",
                    default_value=cur_font_path,
                    hint="Path to .ttf / .otf  (blank = built-in)",
                    width=-148,
                )
                dpg.add_button(label="Browse##font", callback=self.cb_browse_font)
            with dpg.group(horizontal=True):
                dpg.add_text("Size (px)")
                dpg.add_input_float(
                    tag="theme_font_size",
                    default_value=cur_font_size,
                    min_value=8.0,
                    max_value=64.0,
                    step=1.0,
                    width=90,
                    format="%.0f",
                )
                dpg.add_button(label="Apply Font", callback=self.cb_apply_font)
            dpg.add_separator()
            dpg.add_text("UI Scale (DPI)")
            dpg.add_slider_float(
                tag="theme_scale",
                default_value=cur_scale,
                min_value=0.5,
                max_value=3.0,
                width=-1,
                callback=self.cb_scale_changed,
                format="%.2f",
            )

    def _build_context_hub(self):
        with dpg.window(
            label="Context Hub",
            tag="win_context_hub",
            pos=(8, 8),
            width=420,
            height=600,
            no_close=False,
            no_collapse=True,
        ):
            with dpg.group(tag="automated_actions_group", show=False):
                dpg.add_button(tag="btn_project_new_automated", callback=lambda s, a, u: self._cb_new_project_automated(u))

            with dpg.tab_bar():
                with dpg.tab(label="Projects"):
                    proj_meta = self.project.get("project", {})
                    proj_name = proj_meta.get("name", Path(self.active_project_path).stem)
                    dpg.add_text(f"Active: {proj_name}", tag="project_name_text", color=(140, 255, 160))
                    dpg.add_separator()
                    dpg.add_text("Git Directory")
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(
                            tag="project_git_dir",
                            default_value=proj_meta.get("git_dir", ""),
                            width=-100,
                        )
                        dpg.add_button(label="Browse##git", callback=self.cb_browse_git_dir)
                    dpg.add_separator()
                    dpg.add_text("Main Context File")
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(
                            tag="project_main_context",
                            default_value=proj_meta.get("main_context", ""),
                            width=-100,
                        )
                        dpg.add_button(label="Browse##ctx", callback=self.cb_browse_main_context)
                    dpg.add_separator()
                    dpg.add_text("Output Dir")
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(
                            tag="output_dir",
                            default_value=self.project.get("output", {}).get("output_dir", "./md_gen"),
                            width=-100,
                        )
                        dpg.add_button(label="Browse##out", callback=self.cb_browse_output)
                    dpg.add_separator()
                    dpg.add_text("Project Files")
                    with dpg.child_window(tag="projects_scroll", height=120, border=True):
                        pass
                    with dpg.group(horizontal=True):
                        dpg.add_button(label="Add Project", tag="btn_project_add", callback=self.cb_add_project)
                        dpg.add_button(label="New Project", tag="btn_project_new", callback=self.cb_new_project)
                        dpg.add_button(label="Save All", tag="btn_project_save", callback=self.cb_save_config)
                    dpg.add_checkbox(
                        tag="project_word_wrap",
                        label="Word-Wrap (Read-only panels)",
                        default_value=self.project.get("project", {}).get("word_wrap", True),
                        callback=self.cb_word_wrap_toggled
                    )
                    dpg.add_separator()
                    dpg.add_text("Agent Capabilities")
                    agent_tools = self.project.get("agent", {}).get("tools", {})
                    for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
                        dpg.add_checkbox(
                            tag=f"tool_toggle_{t_name}",
                            label=f"Enable {t_name}",
                            default_value=agent_tools.get(t_name, True)
                        )

                with dpg.tab(label="Files"):
                    dpg.add_text("Base Dir")
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(
                            tag="files_base_dir",
                            default_value=self.project.get("files", {}).get("base_dir", "."),
                            width=-100,
                        )
                        dpg.add_button(
                            label="Browse##filesbase", callback=self.cb_browse_files_base
                        )
                    dpg.add_separator()
                    dpg.add_text("Paths")
                    with dpg.child_window(tag="files_scroll", height=-64, border=True):
                        pass
                    dpg.add_separator()
                    with dpg.group(horizontal=True):
                        dpg.add_button(label="Add File(s)", callback=self.cb_add_files)
                        dpg.add_button(label="Add Wildcard", callback=self.cb_add_wildcard)

                with dpg.tab(label="Screenshots"):
                    dpg.add_text("Base Dir")
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(
                            tag="shots_base_dir",
                            default_value=self.project.get("screenshots", {}).get("base_dir", "."),
                            width=-100,
                        )
                        dpg.add_button(
                            label="Browse##shotsbase", callback=self.cb_browse_shots_base
                        )
                    dpg.add_separator()
                    dpg.add_text("Paths")
                    with dpg.child_window(tag="shots_scroll", height=-48, border=True):
                        pass
                    dpg.add_separator()
                    dpg.add_button(label="Add Screenshot(s)", callback=self.cb_add_shots)

    def _build_ai_settings_hub(self):
        with dpg.window(
            label="AI Settings Hub",
            tag="win_ai_settings_hub",
            pos=(8, 616),
            width=420,
            height=556,
            no_close=False,
            no_collapse=True,
        ):
            with dpg.collapsing_header(label="Provider & Models", default_open=True):
                dpg.add_text("Provider")
                dpg.add_combo(
                    tag="provider_combo",
                    items=PROVIDERS,
                    default_value=self.current_provider,
                    width=-1,
                    callback=self.cb_provider_changed,
                )
                with dpg.group(horizontal=True):
                    dpg.add_text("Model")
                    dpg.add_button(label="Fetch Models", callback=self.cb_fetch_models)
                dpg.add_listbox(
                    tag="model_listbox",
                    items=self.available_models,
                    default_value=self.current_model,
                    width=-1,
                    num_items=5,
                    callback=self.cb_model_changed,
                )
                dpg.add_separator()
                dpg.add_text("Telemetry")
                dpg.add_text("History Token Budget:", color=_LABEL_COLOR)
                dpg.add_progress_bar(tag="token_budget_bar", default_value=0.0, width=-1)
                dpg.add_text("0 / 0", tag="token_budget_label")
                dpg.add_text("", tag="gemini_cache_label", show=False)

            with dpg.collapsing_header(label="Parameters", default_open=True):
                dpg.add_input_float(tag="ai_temperature", label="Temperature", default_value=self.temperature, min_value=0.0, max_value=2.0)
                dpg.add_input_int(tag="ai_max_tokens", label="Max Tokens (Output)", default_value=self.max_tokens, step=1024)
                dpg.add_input_int(tag="ai_history_trunc", label="History Truncation Limit", default_value=self.history_trunc_limit, step=1024)

            with dpg.collapsing_header(label="System Prompts", default_open=False):
                dpg.add_text("Global System Prompt")
                dpg.add_input_text(
                    tag="global_system_prompt",
                    default_value=self.config.get("ai", {}).get("system_prompt", ""),
                    multiline=True,
                    width=-1,
                    height=100,
                )
                dpg.add_separator()
                dpg.add_text("Project System Prompt")
                dpg.add_input_text(
                    tag="project_system_prompt",
                    default_value=self.project.get("project", {}).get("system_prompt", ""),
                    multiline=True,
                    width=-1,
                    height=100,
                )

    def _build_discussion_hub(self):
        with dpg.window(
            label="Discussion Hub",
            tag="win_discussion_hub",
            pos=(436, 8),
            width=800,
            height=1164,
            no_close=False,
            no_collapse=True,
        ):
            with dpg.group(horizontal=True):
                dpg.add_text("DISCUSSION", color=_SUBHDR_COLOR)
                dpg.add_spacer(width=20)
                dpg.add_text("THINKING...", tag="thinking_indicator", color=(255, 100, 100), show=False)

            # History at Top
            with dpg.child_window(tag="disc_history_section", height=-400, border=True):
                # Discussion selector section
                with dpg.collapsing_header(label="Discussions", default_open=False):
                    with dpg.group(tag="disc_selector_group"):
                        pass  # populated by _rebuild_discussion_selector

                dpg.add_separator()

                # Entry toolbar
                with dpg.group(horizontal=True):
                    dpg.add_button(label="+ Entry", callback=self.cb_disc_append_entry)
                    dpg.add_button(label="-All", callback=self.cb_disc_collapse_all)
                    dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
                    dpg.add_text("Keep Pairs:", color=(160, 160, 160))
                    dpg.add_input_int(tag="disc_truncate_pairs", default_value=2, width=80, min_value=1)
                    dpg.add_button(label="Truncate", tag="btn_disc_truncate", callback=self.cb_disc_truncate)
                    dpg.add_button(label="Clear All", callback=self.cb_disc_clear)
                    dpg.add_button(label="Save", callback=self.cb_disc_save)
                
                dpg.add_checkbox(
                    tag="auto_add_history",
                    label="Auto-add message & response to history",
                    default_value=self.project.get("discussion", {}).get("auto_add", False)
                )
                dpg.add_separator()            
                with dpg.collapsing_header(label="Roles", default_open=False):
                    with dpg.child_window(tag="disc_roles_scroll", height=96, border=True):
                        pass
                    with dpg.group(horizontal=True):
                        dpg.add_input_text(tag="disc_new_role_input", hint="New role name", width=-72)
                        dpg.add_button(label="Add", callback=self.cb_disc_add_role)
                dpg.add_separator()
                with dpg.child_window(tag="disc_scroll", height=-1, border=False):
                    pass

            dpg.add_separator()

            # Interaction Tabs at Bottom
            with dpg.tab_bar():
                with dpg.tab(label="Message"):
                    dpg.add_input_text(
                        tag="ai_input",
                        multiline=True,
                        width=-1,
                        height=200,
                    )
                    with dpg.group(horizontal=True):
                        dpg.add_button(label="Gen + Send", tag="btn_gen_send", callback=self.cb_generate_send)
                        dpg.add_button(label="MD Only", tag="btn_md_only", callback=self.cb_md_only)
                        dpg.add_button(label="Reset", tag="btn_reset", callback=self.cb_reset_session)
                        dpg.add_button(label="-> History", tag="btn_to_history", callback=self.cb_append_message_to_history)

                with dpg.tab(label="AI Response"):
                    dpg.add_input_text(
                        tag="ai_response",
                        multiline=True,
                        readonly=True,
                        width=-1,
                        height=-48,
                    )
                    with dpg.child_window(tag="ai_response_wrap_container", width=-1, height=-48, border=True, show=False):
                        dpg.add_text("", tag="ai_response_wrap", wrap=0)
                    dpg.add_separator()
                    dpg.add_button(label="-> History", callback=self.cb_append_response_to_history)

    def _build_operations_hub(self):
        with dpg.window(
            label="Operations Hub",
            tag="win_operations_hub",
            pos=(1244, 8),
            width=428,
            height=1164,
            no_close=False,
            no_collapse=True,
        ):
            with dpg.group(horizontal=True):
                dpg.add_text("OPERATIONS", color=_SUBHDR_COLOR)
                dpg.add_spacer(width=20)
                dpg.add_text("LIVE", tag="operations_live_indicator", color=(100, 255, 100), show=False)

            with dpg.tab_bar(tag="operations_tabs"):
                with dpg.tab(label="Comms Log", tag="tab_comms"):
                    with dpg.group(horizontal=True):
                        dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160))
                        dpg.add_spacer(width=16)
                        dpg.add_button(label="Clear", callback=self.cb_clear_comms)
                        dpg.add_button(label="Load Log", tag="btn_load_log", callback=self.cb_load_prior_log)
                        dpg.add_button(label="Exit Prior", tag="exit_prior_btn", callback=self.cb_exit_prior_session, show=False)
                    
                    dpg.add_text("PRIOR SESSION VIEW", tag="prior_session_indicator", color=(255, 100, 100), show=False)
                    dpg.add_text("Tokens: 0 (In: 0 Out: 0)", tag="ai_token_usage", color=(180, 255, 180))
                    dpg.add_separator()
                    with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True):
                        pass

                with dpg.tab(label="Tool Log", tag="tab_tool"):
                    with dpg.group(horizontal=True):
                        dpg.add_text("Tool call history")
                        dpg.add_button(label="Clear", callback=self.cb_clear_tool_log)
                    dpg.add_separator()
                    with dpg.child_window(tag="tool_log_scroll", height=-1, border=False):
                        pass

    def _build_diagnostics_window(self):
        with dpg.window(
            label="Diagnostics",
            tag="win_diagnostics",
            pos=(1244, 804),
            width=428,
            height=360,
            no_close=False,
            no_collapse=True,
        ):
            dpg.add_text("Performance Telemetry")
            with dpg.table(header_row=False, borders_innerH=True, borders_outerH=True, borders_innerV=True, borders_outerV=True):
                dpg.add_table_column()
                dpg.add_table_column()
                dpg.add_table_column()
                dpg.add_table_column()
                with dpg.table_row():
                    dpg.add_text("FPS", color=_LABEL_COLOR)
                    dpg.add_text("0.0", tag="perf_fps_text", color=(180, 255, 180))
                    dpg.add_text("Frame", color=_LABEL_COLOR)
                    dpg.add_text("0.0ms", tag="perf_frame_text", color=(100, 200, 255))
                with dpg.table_row():
                    dpg.add_text("CPU", color=_LABEL_COLOR)
                    dpg.add_text("0.0%", tag="perf_cpu_text", color=(255, 220, 100))
                    dpg.add_text("Lag", color=_LABEL_COLOR)
                    dpg.add_text("0.0ms", tag="perf_lag_text", color=(255, 180, 80))
            
            dpg.add_spacer(height=4)
            dpg.add_plot(label="Frame Time (ms)", tag="plot_frame", height=140, width=-1, no_mouse_pos=True)
            dpg.add_plot_axis(dpg.mvXAxis, label="samples", no_tick_labels=True, parent="plot_frame")
            with dpg.plot_axis(dpg.mvYAxis, label="ms", tag="axis_frame_y", parent="plot_frame"):
                dpg.add_line_series(list(range(100)), self.perf_history["frame_time"], label="frame time", tag="perf_frame_plot")
                dpg.set_axis_limits("axis_frame_y", 0, 50)

            dpg.add_plot(label="CPU Usage (%)", tag="plot_cpu", height=140, width=-1, no_mouse_pos=True)
            dpg.add_plot_axis(dpg.mvXAxis, label="samples", no_tick_labels=True, parent="plot_cpu")
            with dpg.plot_axis(dpg.mvYAxis, label="%", tag="axis_cpu_y", parent="plot_cpu"):
                dpg.add_line_series(list(range(100)), self.perf_history["cpu"], label="cpu usage", tag="perf_cpu_plot")
                dpg.set_axis_limits("axis_cpu_y", 0, 100)

    def _build_ui(self):
        # Performance tracking handlers
        with dpg.handler_registry():
            dpg.add_mouse_click_handler(callback=lambda: self.perf_monitor.record_input_event())
            dpg.add_key_press_handler(callback=lambda: self.perf_monitor.record_input_event())

        with dpg.viewport_menu_bar():
            with dpg.menu(label="Windows"):
                for label, tag in self.window_info.items():
                    dpg.add_menu_item(label=label, callback=lambda s, a, u: dpg.show_item(u), user_data=tag)
            with dpg.menu(label="Project"):
                dpg.add_menu_item(label="Save All", callback=self.cb_save_config)
                dpg.add_menu_item(label="Reset Session", callback=self.cb_reset_session)
                dpg.add_menu_item(label="Generate MD Only", callback=self.cb_md_only)

        # Build Hubs
        self._build_context_hub()
        self._build_ai_settings_hub()
        self._build_discussion_hub()
        self._build_operations_hub()
        self._build_diagnostics_window()

        self._build_theme_window()

        # ---- Script Output Popup ----
        with dpg.window(
            label="Last Script Output",
            tag="win_script_output",
            show=False,
            width=800,
            height=600,
            pos=(100, 100),
            no_collapse=True
        ):
            with dpg.group(horizontal=True):
                dpg.add_text("Script:")
                dpg.add_button(
                    label="[+ Maximize]",
                    callback=lambda s, a, u: _show_text_viewer("Last Script", self._last_script),
                )
            dpg.add_input_text(
                tag="last_script_text",
                multiline=True,
                readonly=True,
                width=-1,
                height=200,
            )
            with dpg.child_window(tag="last_script_text_wrap_container", width=-1, height=200, border=True, show=False):
                dpg.add_text("", tag="last_script_text_wrap", wrap=0)
            dpg.add_separator()
            with dpg.group(horizontal=True):
                dpg.add_text("Output:")
                dpg.add_button(
                    label="[+ Maximize]",
                    callback=lambda s, a, u: _show_text_viewer("Last Output", self._last_output),
                )
            dpg.add_input_text(
                tag="last_script_output",
                multiline=True,
                readonly=True,
                width=-1,
                height=-1,
            )
            with dpg.child_window(tag="last_script_output_wrap_container", width=-1, height=-1, border=True, show=False):
                dpg.add_text("", tag="last_script_output_wrap", wrap=0)

        # ---- Global Text Viewer Popup ----
        with dpg.window(
            label="Text Viewer",
            tag="win_text_viewer",
            show=False,
            width=900,
            height=700,
            pos=(150, 150),
            no_collapse=True
        ):
            dpg.add_input_text(
                tag="text_viewer_content",
                multiline=True,
                readonly=True,
                width=-1,
                height=-1,
            )
            with dpg.child_window(tag="text_viewer_wrap_container", width=-1, height=-1, border=False, show=False):
                dpg.add_text("", tag="text_viewer_wrap", wrap=0)

    def _process_pending_gui_tasks(self):
        """Processes tasks queued from background threads on the main thread."""
        if not self._pending_gui_tasks:
            return
            
        with self._pending_gui_tasks_lock:
            gui_tasks = self._pending_gui_tasks[:]
            self._pending_gui_tasks.clear()
            
        for task in gui_tasks:
            try:
                action = task.get("action")
                if action == "set_value":
                    item = task.get("item")
                    val = task.get("value")
                    if item and dpg.does_item_exist(item):
                        dpg.set_value(item, val)
                elif action == "click":
                    item = task.get("item")
                    args = task.get("args", [])
                    kwargs = task.get("kwargs", {})
                    user_data = task.get("user_data")
                    if item and dpg.does_item_exist(item):
                        cb = dpg.get_item_callback(item)
                        if cb:
                            try:
                                # DPG callbacks can have (sender, app_data, user_data)
                                # If we have specific args/kwargs we use them, 
                                # otherwise we try to follow the DPG pattern.
                                if args or kwargs:
                                    cb(*args, **kwargs)
                                elif user_data is not None:
                                    cb(item, None, user_data)
                                else:
                                    cb()
                            except Exception as e:
                                print(f"Error in GUI hook callback for {item}: {e}")
                elif action == "select_tab":
                    tab_bar = task.get("tab_bar")
                    tab = task.get("tab")
                    if tab_bar and dpg.does_item_exist(tab_bar):
                        dpg.set_value(tab_bar, tab)
                elif action == "select_list_item":
                    listbox = task.get("listbox")
                    val = task.get("item_value")
                    if listbox and dpg.does_item_exist(listbox):
                        dpg.set_value(listbox, val)
                        cb = dpg.get_item_callback(listbox)
                        if cb:
                            # Dear PyGui callbacks for listbox usually receive (sender, app_data, user_data)
                            # app_data is the selected value.
                            cb(listbox, val)
                elif action == "custom_callback":
                    cb = task.get("callback")
                    if cb:
                        try:
                            cb()
                        except Exception as e:
                            print(f"Error in custom GUI hook callback: {e}")
                elif action == "refresh_api_metrics":
                    self._refresh_api_metrics(task.get("payload", {}))
            except Exception as e:
                print(f"Error executing GUI hook task: {e}")

    def run(self):
        dpg.create_context()
        dpg.configure_app(docking=True, docking_space=True, init_file="dpg_layout.ini")
        dpg.create_viewport(title="manual slop", width=1680, height=1200)
        dpg.setup_dearpygui()
        dpg.show_viewport()
        dpg.maximize_viewport()
        self._build_ui()
        theme.load_from_config(self.config)
        self._rebuild_files_list()
        self._rebuild_shots_list()
        self._rebuild_disc_list()
        self._rebuild_disc_roles_list()
        self._rebuild_projects_list()
        self._rebuild_discussion_selector()
        self._fetch_models(self.current_provider)
        
        self.hook_server.start()

        while dpg.is_dearpygui_running():
            self.perf_monitor.start_frame()
            
            # Show any pending confirmation dialog on the main thread safely
            self.perf_monitor.start_component("Dialogs")
            with self._pending_dialog_lock:
                dialog = self._pending_dialog
                self._pending_dialog = None
            if dialog is not None:
                dialog.show()
            self.perf_monitor.end_component("Dialogs")

            # Process queued history additions
            self.perf_monitor.start_component("History")
            with self._pending_history_adds_lock:
                adds = self._pending_history_adds[:]
                self._pending_history_adds.clear()
            if adds:
                for item in adds:
                    if item["role"] not in self.disc_roles:
                        self.disc_roles.append(item["role"])
                        self._rebuild_disc_roles_list()
                    self.disc_entries.append(item)
                    self._render_disc_entry(len(self.disc_entries) - 1, item)
                
                if dpg.does_item_exist("disc_scroll"):
                    # Force scroll to bottom using a very large number
                    dpg.set_y_scroll("disc_scroll", 99999)
            self.perf_monitor.end_component("History")

            # Process queued API GUI tasks
            self.perf_monitor.start_component("GUI_Tasks")
            self._process_pending_gui_tasks()
            self.perf_monitor.end_component("GUI_Tasks")

            self.perf_monitor.start_component("Blinking")
            
            # Thinking Indicator Blink (Continuous while shown)
            if dpg.does_item_exist("thinking_indicator") and dpg.is_item_shown("thinking_indicator"):
                elapsed = time.time()
                val = math.sin(elapsed * 10 * math.pi)
                alpha = 255 if val > 0 else 0
                dpg.configure_item("thinking_indicator", color=(255, 100, 100, alpha))

            if dpg.does_item_exist("operations_live_indicator") and dpg.is_item_shown("operations_live_indicator"):
                elapsed = time.time()
                val = math.sin(elapsed * 10 * math.pi)
                alpha = 255 if val > 0 else 0
                dpg.configure_item("operations_live_indicator", color=(100, 255, 100, alpha))

            if self._trigger_script_blink:
                self._trigger_script_blink = False
                self._is_script_blinking = True
                self._script_blink_start_time = time.time()
                if dpg.does_item_exist("win_script_output"):
                    dpg.show_item("win_script_output")
                    # dpg.focus_item("win_script_output")  # Focus can sometimes be jarring, but requested
            
            if self._is_script_blinking:
                elapsed = time.time() - self._script_blink_start_time
                if elapsed > 1.5:
                    self._is_script_blinking = False
                    if dpg.does_item_exist("script_blink_theme"):
                        try:
                            dpg.bind_item_theme("last_script_output", 0)
                            dpg.bind_item_theme("last_script_text", 0)
                            if dpg.does_item_exist("last_script_output_wrap_container"):
                                dpg.bind_item_theme("last_script_output_wrap_container", 0)
                            if dpg.does_item_exist("last_script_text_wrap_container"):
                                dpg.bind_item_theme("last_script_text_wrap_container", 0)
                        except Exception:
                            pass
                else:
                    val = math.sin(elapsed * 8 * math.pi)
                    alpha = 60 if val > 0 else 0
                    
                    if alpha != self._last_script_alpha:
                        self._last_script_alpha = alpha
                        if not dpg.does_item_exist("script_blink_theme"):
                            with dpg.theme(tag="script_blink_theme"):
                                with dpg.theme_component(dpg.mvAll):
                                    dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 100, 255, alpha), tag="script_blink_color")
                                    dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (0, 100, 255, alpha), tag="script_blink_color2")
                        else:
                            dpg.set_value("script_blink_color", [0, 100, 255, alpha])
                            if dpg.does_item_exist("script_blink_color2"):
                                dpg.set_value("script_blink_color2", [0, 100, 255, alpha])
                        
                        if dpg.does_item_exist("last_script_output"):
                            try:
                                dpg.bind_item_theme("last_script_output", "script_blink_theme")
                                dpg.bind_item_theme("last_script_text", "script_blink_theme")
                                if dpg.does_item_exist("last_script_output_wrap_container"):
                                    dpg.bind_item_theme("last_script_output_wrap_container", "script_blink_theme")
                                if dpg.does_item_exist("last_script_text_wrap_container"):
                                    dpg.bind_item_theme("last_script_text_wrap_container", "script_blink_theme")
                            except Exception:
                                pass

            if self._trigger_blink:
                self._trigger_blink = False
                self._is_blinking = True
                self._blink_start_time = time.time()
                if dpg.does_item_exist("win_discussion_hub"):
                    dpg.focus_item("win_discussion_hub")
            
            if self._is_blinking:
                elapsed = time.time() - self._blink_start_time
                if elapsed > 1.5:
                    self._is_blinking = False
                    if dpg.does_item_exist("response_blink_theme"):
                        try:
                            dpg.bind_item_theme("ai_response", 0)
                            if dpg.does_item_exist("ai_response_wrap_container"):
                                dpg.bind_item_theme("ai_response_wrap_container", 0)
                        except Exception:
                            pass
                else:
                    # Square-wave style retro blink (4 times per second)
                    val = math.sin(elapsed * 8 * math.pi)
                    alpha = 50 if val > 0 else 0
                    
                    if alpha != self._last_resp_alpha:
                        self._last_resp_alpha = alpha
                        if not dpg.does_item_exist("response_blink_theme"):
                            with dpg.theme(tag="response_blink_theme"):
                                with dpg.theme_component(dpg.mvAll):
                                    dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 255, 0, alpha), tag="response_blink_color")
                                    dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (0, 255, 0, alpha), tag="response_blink_color2")
                        else:
                            dpg.set_value("response_blink_color", [0, 255, 0, alpha])
                            if dpg.does_item_exist("response_blink_color2"):
                                dpg.set_value("response_blink_color2", [0, 255, 0, alpha])
                        
                        if dpg.does_item_exist("ai_response"):
                            try:
                                dpg.bind_item_theme("ai_response", "response_blink_theme")
                                if dpg.does_item_exist("ai_response_wrap_container"):
                                    dpg.bind_item_theme("ai_response_wrap_container", "response_blink_theme")
                            except Exception:
                                pass
            self.perf_monitor.end_component("Blinking")

            # Flush any comms entries queued from background threads
            self.perf_monitor.start_component("Comms")
            self._flush_pending_comms()
            self.perf_monitor.end_component("Comms")

            self.perf_monitor.start_component("Telemetry")
            self._update_performance_diagnostics()
            self.perf_monitor.end_component("Telemetry")

            self.perf_monitor.end_frame()
            dpg.render_dearpygui_frame()

        # Save everything on exit
        self._flush_to_project()
        self._save_active_project()
        self._flush_to_config()
        save_config(self.config)

        dpg.save_init_file("dpg_layout.ini")
        session_logger.close_session()
        self.perf_monitor.stop()
        ai_client.cleanup()  # Destroy active API caches to stop billing
        self.hook_server.stop()
        dpg.destroy_context()


def main():
    app = App()
    app.run()


if __name__ == "__main__":
    main()




