# gui.py import dearpygui.dearpygui as dpg import tomllib import tomli_w import threading import time import math 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 theme 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 # ------------------------------------------------------------------ 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 _add_text_field(parent: str, label: str, value: str): """Render a labelled text value; long values get a scrollable box.""" with dpg.group(horizontal=False, parent=parent): dpg.add_text(f"{label}:", color=_LABEL_COLOR) if len(value) > COMMS_CLAMP_CHARS: dpg.add_input_text( default_value=value, multiline=True, readonly=True, width=-1, height=80, ) else: dpg.add_text(value if value else "(empty)", wrap=0, color=_VALUE_COLOR) def _add_kv_row(parent: str, key: str, val, val_color=None): """Single key: value row, horizontally laid out.""" vc = val_color or _VALUE_COLOR with dpg.group(horizontal=True, parent=parent): dpg.add_text(f"{key}:", color=_LABEL_COLOR) dpg.add_text(str(val), color=vc) 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, 440 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() 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() # ---- 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.0-flash") 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._pending_dialog: ConfirmDialog | None = None self._pending_dialog_lock = threading.Lock() self._tool_log: list[tuple[str, 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() # Blink state self._trigger_blink = False self._is_blinking = False self._blink_start_time = 0.0 session_logger.open_session() 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 # ---------------------------------------------------------------- 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._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)) 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 _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) 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._tool_log.append((script, result)) self._rebuild_tool_log() def _rebuild_tool_log(self): if not dpg.does_item_exist("tool_log_scroll"): return 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)" dpg.add_text(f"Call #{i}: {first_line}", color=(140, 200, 255)) 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") # 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, } 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}") def _update_response(self, text: str): self.ai_response = text if dpg.does_item_exist("ai_response"): dpg.set_value("ai_response", 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 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", callback=self.cb_disc_create) dpg.add_button(label="Rename", callback=self.cb_disc_rename) dpg.add_button(label="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_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_reset_session(self): ai_client.reset_session() ai_client.clear_comms_log() self._tool_log.clear() self._rebuild_tool_log() 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)) 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 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_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_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 _rebuild_disc_list(self): 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): collapsed = entry.get("collapsed", False) preview = entry["content"].replace("\n", " ")[:60] if len(entry["content"]) > 60: preview += "..." ts_str = entry.get("ts", "") with dpg.group(parent="disc_scroll"): 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_combo( tag=f"disc_role_{i}", items=self.disc_roles, default_value=entry["role"], width=120, callback=self._make_disc_role_cb(i), ) 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): dpg.add_input_text( tag=f"disc_content_{i}", default_value=entry["content"], multiline=True, width=-1, height=100, callback=self._make_disc_content_cb(i), on_enter=False, ) dpg.add_separator() 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=True, ): 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_ui(self): # ---- Projects panel ---- with dpg.window( label="Projects", tag="win_projects", pos=(8, 8), width=400, height=340, no_close=True, ): 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=-40, border=True): pass with dpg.group(horizontal=True): dpg.add_button(label="Add Project", callback=self.cb_add_project) dpg.add_button(label="New Project", callback=self.cb_new_project) dpg.add_button(label="Save All", callback=self.cb_save_config) # ---- Files panel ---- with dpg.window( label="Files", tag="win_files", pos=(8, 356), width=400, height=400, no_close=True, ): 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=-220, ) 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) # ---- Screenshots panel ---- with dpg.window( label="Screenshots", tag="win_screenshots", pos=(416, 8), width=400, height=500, no_close=True, ): 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=-220, ) 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 self._rebuild_shots_list() dpg.add_separator() dpg.add_button(label="Add Screenshot(s)", callback=self.cb_add_shots) # ---- Discussion History panel ---- with dpg.window( label="Discussion History", tag="win_discussion", pos=(824, 8), width=420, height=600, no_close=True, ): # Discussion selector section with dpg.collapsing_header(label="Discussions", default_open=True): 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_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 # ---- Provider panel ---- with dpg.window( label="Provider", tag="win_provider", pos=(1252, 8), width=420, height=260, no_close=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, ) dpg.add_separator() 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=6, callback=self.cb_model_changed, ) # ---- Message panel ---- with dpg.window( label="Message", tag="win_message", pos=(1252, 276), width=420, height=280, no_close=True, ): dpg.add_input_text( tag="ai_input", multiline=True, width=-1, height=-64, ) dpg.add_separator() with dpg.group(horizontal=True): dpg.add_button(label="Gen + Send", callback=self.cb_generate_send) dpg.add_button(label="MD Only", callback=self.cb_md_only) dpg.add_button(label="Reset", callback=self.cb_reset_session) dpg.add_button(label="-> History", callback=self.cb_append_message_to_history) # ---- Response panel ---- with dpg.window( label="Response", tag="win_response", pos=(1252, 564), width=420, height=300, no_close=True, ): dpg.add_input_text( tag="ai_response", multiline=True, readonly=True, width=-1, height=-48, ) dpg.add_separator() dpg.add_button(label="-> History", callback=self.cb_append_response_to_history) # ---- Tool Calls panel ---- with dpg.window( label="Tool Calls", tag="win_tool_log", pos=(1252, 872), width=420, height=300, no_close=True, ): 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 # ---- Comms History panel ---- with dpg.window( label="Comms History", tag="win_comms", pos=(1680, 8), width=520, height=1164, no_close=True, ): 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_separator() with dpg.group(horizontal=True): dpg.add_text("OUT", color=_DIR_COLORS["OUT"]) dpg.add_text("request", color=_KIND_COLORS["request"]) dpg.add_text("tool_call", color=_KIND_COLORS["tool_call"]) dpg.add_spacer(width=8) dpg.add_text("IN", color=_DIR_COLORS["IN"]) dpg.add_text("response", color=_KIND_COLORS["response"]) dpg.add_text("tool_result", color=_KIND_COLORS["tool_result"]) dpg.add_separator() with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True): pass # ---- System Prompts panel ---- with dpg.window( label="System Prompts", tag="win_system_prompts", pos=(416, 804), width=400, height=300, no_close=True, ): dpg.add_text("Global System Prompt (all projects)") 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, ) self._build_theme_window() 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) while dpg.is_dearpygui_running(): # Show any pending confirmation dialog on the main thread safely with self._pending_dialog_lock: dialog = self._pending_dialog self._pending_dialog = None if dialog is not None: dialog.show() # Process queued history additions 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._rebuild_disc_list() if dpg.does_item_exist("disc_scroll"): # Force scroll to bottom using a very large number dpg.set_y_scroll("disc_scroll", 99999) # Handle retro arcade blinking effect if self._trigger_blink: self._trigger_blink = False self._is_blinking = True self._blink_start_time = time.time() if dpg.does_item_exist("win_response"): dpg.focus_item("win_response") 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) except Exception: pass else: # Square-wave style retro blink (4 times per second) val = math.sin(elapsed * 8 * math.pi) alpha = 120 if val > 0 else 0 if not dpg.does_item_exist("response_blink_theme"): with dpg.theme(tag="response_blink_theme"): with dpg.theme_component(dpg.mvInputText): dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 255, 0, alpha), tag="response_blink_color") else: dpg.set_value("response_blink_color", [0, 255, 0, alpha]) if dpg.does_item_exist("ai_response"): try: dpg.bind_item_theme("ai_response", "response_blink_theme") except Exception: pass # Flush any comms entries queued from background threads self._flush_pending_comms() 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() dpg.destroy_context() def main(): app = App() app.run() if __name__ == "__main__": main()