# gui.py import dearpygui.dearpygui as dpg import tomllib import tomli_w import threading 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 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=460, 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"]) _add_text_field(parent, "script", payload.get("script", "")) 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}" self._script = script self._base_dir = base_dir self._event = threading.Event() self._approved = False def show(self): """Called from main thread only.""" 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) def _cb_approve(self): self._script = dpg.get_value(f"{self._tag}_script") self._approved = True self._event.set() dpg.delete_item(self._tag) def _cb_reject(self): self._approved = False self._event.set() dpg.delete_item(self._tag) 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} dicts. Each TOML string is one excerpt (may contain multiple role blocks separated by lines like "Role:" or "[Role]"). We detect the common patterns: "User:\n..." "AI:\n..." "[User]\n..." "[AI]\n..." and split accordingly. Unrecognised text becomes a User entry. roles: list of known role strings (defaults to module DISC_ROLES if None). """ import re known = roles if roles is not None else DISC_ROLES entries: list[dict] = [] role_pattern = re.compile( r'^(?:\[)?(' + '|'.join(re.escape(r) for r in known) + r')(?:\])?:?\s*$', re.IGNORECASE | re.MULTILINE, ) for excerpt in history: # Find all role header positions splits = [(m.start(), m.end(), m.group(1)) for m in role_pattern.finditer(excerpt)] if not splits: # No role headers found - treat whole excerpt as User content text = excerpt.strip() if text: entries.append({"role": "User", "content": text, "collapsed": False}) continue # Extract content between headers for idx, (start, end, role) in enumerate(splits): next_start = splits[idx + 1][0] if idx + 1 < len(splits) else len(excerpt) content = excerpt[end:next_start].strip() # Normalise role capitalisation to match known list matched = next((r for r in known if r.lower() == role.lower()), role) entries.append({"role": matched, "content": content, "collapsed": False}) return entries class App: def __init__(self): self.config = load_config() self.files: list[str] = list(self.config["files"].get("paths", [])) self.screenshots: list[str] = list( self.config.get("screenshots", {}).get("paths", []) ) self.history: list[str] = list( self.config.get("discussion", {}).get("history", []) ) self.disc_roles: list[str] = list( self.config.get("discussion", {}).get("roles", list(DISC_ROLES)) ) self.disc_entries: list[dict] = _parse_history_entries(self.history, self.disc_roles) 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] = [] 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 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 # ---------------------------------------------------------------- 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_config(self): self.config["output"]["namespace"] = dpg.get_value("namespace") self.config["output"]["output_dir"] = dpg.get_value("output_dir") self.config["files"]["base_dir"] = dpg.get_value("files_base_dir") if "screenshots" not in self.config: self.config["screenshots"] = {} self.config["screenshots"]["base_dir"] = dpg.get_value("shots_base_dir") self.config["files"]["paths"] = self.files self.config["screenshots"]["paths"] = self.screenshots # Pull latest content edits from disc 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) self.history = self._disc_serialize() self.config["discussion"] = {"history": self.history, "roles": self.disc_roles} self.config["ai"] = { "provider": self.current_provider, "model": self.current_model, } theme.save_to_config(self.config) def _do_generate(self) -> tuple[str, Path, list]: self._flush_to_config() save_config(self.config) return aggregate.run(self.config) 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 _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 _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_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_add_excerpt(self): current = dpg.get_value("discussion_box") dpg.set_value("discussion_box", current + "\n---\n") def cb_clear_discussion(self): dpg.set_value("discussion_box", "") def cb_save_discussion(self): self._flush_to_config() save_config(self.config) self._update_status("discussion saved") 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() # Clear pending queue and counter, then wipe the comms panel 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") file_items_snap = self.last_file_items def do_send(): try: response = ai_client.send(self.last_md, user_msg, base_dir) self._update_response(response) self._update_status("done") except Exception as e: self._update_response(f"ERROR: {e}") self._update_status("error") 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) self._rebuild_disc_list() 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) # ---------------------------------------------------------------- build ui # ------------------------------------------------------------ disc history def _disc_serialize(self) -> list[str]: """Flatten disc_entries back to a single TOML history string per logical block.""" lines = [] for e in self.disc_entries: lines.append(f"{e['role']}:\n{e['content']}") return lines 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 += "..." 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=160, callback=self._make_disc_role_cb(i), ) 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": ""}) 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 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 _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() 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}) self._rebuild_disc_list() def cb_disc_clear(self): self.disc_entries.clear() self._rebuild_disc_list() def cb_disc_save(self): # Pull any in-progress edits from widgets into disc_entries 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) self._flush_to_config() save_config(self.config) self._update_status("discussion saved") 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}) 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}) self._rebuild_disc_list() # ------------------------------------------------------------ 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 {} 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): with dpg.window( label="Config", tag="win_config", pos=(8, 8), width=400, height=200, no_close=True, ): dpg.add_text("Namespace") dpg.add_input_text( tag="namespace", default_value=self.config["output"]["namespace"], width=-1, ) dpg.add_text("Output Dir") dpg.add_input_text( tag="output_dir", default_value=self.config["output"]["output_dir"], width=-1, ) with dpg.group(horizontal=True): dpg.add_button(label="Browse Output Dir", callback=self.cb_browse_output) dpg.add_button(label="Save Config", callback=self.cb_save_config) with dpg.window( label="Files", tag="win_files", pos=(8, 216), width=400, height=500, no_close=True, ): dpg.add_text("Base Dir") with dpg.group(horizontal=True): dpg.add_input_text( tag="files_base_dir", default_value=self.config["files"]["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 self._rebuild_files_list() 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.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.config.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) with dpg.window( label="Discussion History", tag="win_discussion", pos=(824, 8), width=420, height=600, no_close=True, ): 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_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 with dpg.window( label="Provider", tag="win_provider", pos=(1232, 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, ) with dpg.window( label="Message", tag="win_message", pos=(1232, 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) with dpg.window( label="Response", tag="win_response", pos=(1232, 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) with dpg.window( label="Tool Calls", tag="win_tool_log", pos=(1232, 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 (new) ---- with dpg.window( label="Comms History", tag="win_comms", pos=(1660, 8), width=520, height=1164, no_close=True, ): # Status line lives here now 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() # Colour legend 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 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_disc_list() self._rebuild_disc_roles_list() self._fetch_models(self.current_provider) while dpg.is_dearpygui_running(): # Show any pending confirmation dialog on the main thread with self._pending_dialog_lock: dialog = self._pending_dialog self._pending_dialog = None if dialog is not None: dialog.show() # Flush any comms entries queued from background threads self._flush_pending_comms() dpg.render_dearpygui_frame() dpg.save_init_file("dpg_layout.ini") session_logger.close_session() dpg.destroy_context() def main(): app = App() app.run() if __name__ == "__main__": main()