From ddb53b250faff410b0958ceb31aac4375084e42d Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 23 Feb 2026 22:15:13 -0500 Subject: [PATCH] refactor(gui2): Restructure layout into discrete Hubs Automates the refactoring of the monolithic _gui_func in gui_2.py into separate rendering methods, nested within 'Context Hub', 'AI Settings Hub', 'Discussion Hub', and 'Operations Hub', utilizing tab bars. Adds tests to ensure the new default windows correctly represent this Hub structure. --- gui_2.py | 1574 +++++++++++++++++++------------------ refactor_gui2.py | 243 ++++++ tests/test_gui2_layout.py | 46 ++ 3 files changed, 1083 insertions(+), 780 deletions(-) create mode 100644 refactor_gui2.py create mode 100644 tests/test_gui2_layout.py diff --git a/gui_2.py b/gui_2.py index 222120d..70c0b09 100644 --- a/gui_2.py +++ b/gui_2.py @@ -158,17 +158,10 @@ class App: self.models_thread: threading.Thread | None = None _default_windows = { - "Projects": True, - "Files": True, - "Screenshots": True, - "Discussion History": True, - "Provider": True, - "Message": True, - "Response": True, - "Tool Calls": True, - "Comms History": True, - "System Prompts": True, - "Theme": True, + "Context Hub": True, + "AI Settings Hub": True, + "Discussion Hub": True, + "Operations Hub": True, "Diagnostics": False, } saved = self.config.get("gui", {}).get("show_windows", {}) @@ -689,786 +682,71 @@ class App: # imgui.end_menu() # imgui.end_main_menu_bar() - # ---- Projects - if self.show_windows["Projects"]: - exp, self.show_windows["Projects"] = imgui.begin("Projects", self.show_windows["Projects"]) + + # ---- Context Hub + if self.show_windows.get("Context Hub", False): + exp, self.show_windows["Context Hub"] = imgui.begin("Context Hub", self.show_windows["Context Hub"]) if exp: - proj_name = self.project.get("project", {}).get("name", Path(self.active_project_path).stem) - imgui.text_colored(C_IN, f"Active: {proj_name}") - imgui.separator() - imgui.text("Git Directory") - ch, self.ui_project_git_dir = imgui.input_text("##git_dir", self.ui_project_git_dir) - imgui.same_line() - if imgui.button("Browse##git"): - r = hide_tk_root() - d = filedialog.askdirectory(title="Select Git Directory") - r.destroy() - if d: self.ui_project_git_dir = d - - imgui.separator() - imgui.text("Main Context File") - ch, self.ui_project_main_context = imgui.input_text("##main_ctx", self.ui_project_main_context) - imgui.same_line() - if imgui.button("Browse##ctx"): - r = hide_tk_root() - p = filedialog.askopenfilename(title="Select Main Context File") - r.destroy() - if p: self.ui_project_main_context = p - - imgui.separator() - imgui.text("Output Dir") - ch, self.ui_output_dir = imgui.input_text("##out_dir", self.ui_output_dir) - imgui.same_line() - if imgui.button("Browse##out"): - r = hide_tk_root() - d = filedialog.askdirectory(title="Select Output Dir") - r.destroy() - if d: self.ui_output_dir = d - - imgui.separator() - imgui.text("Project Files") - imgui.begin_child("proj_files", imgui.ImVec2(0, 150), True) - for i, pp in enumerate(self.project_paths): - is_active = (pp == self.active_project_path) - if imgui.button(f"x##p{i}"): - removed = self.project_paths.pop(i) - if removed == self.active_project_path and self.project_paths: - self._switch_project(self.project_paths[0]) - break - imgui.same_line() - marker = " *" if is_active else "" - if is_active: imgui.push_style_color(imgui.Col_.text, C_IN) - if imgui.button(f"{Path(pp).stem}{marker}##ps{i}"): - self._switch_project(pp) - if is_active: imgui.pop_style_color() - imgui.same_line() - imgui.text_colored(C_LBL, pp) - imgui.end_child() - - if imgui.button("Add Project"): - r = hide_tk_root() - p = filedialog.askopenfilename( - title="Select Project .toml", - filetypes=[("TOML", "*.toml"), ("All", "*.*")], - ) - r.destroy() - if p and p not in self.project_paths: - self.project_paths.append(p) - imgui.same_line() - if imgui.button("New Project"): - r = hide_tk_root() - p = filedialog.asksaveasfilename(title="Create New Project .toml", defaultextension=".toml", filetypes=[("TOML", "*.toml"), ("All", "*.*")]) - r.destroy() - if p: - 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) - imgui.same_line() - if imgui.button("Save All"): - self._flush_to_project() - self._save_active_project() - self._flush_to_config() - save_config(self.config) - self.ai_status = "config saved" - - ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap) - ch, self.ui_summary_only = imgui.checkbox("Summary Only (send file structure, not full content)", self.ui_summary_only) - - if imgui.collapsing_header("Agent Tools"): - for t_name in AGENT_TOOL_NAMES: - val = self.ui_agent_tools.get(t_name, True) - ch, val = imgui.checkbox(f"Enable {t_name}", val) - if ch: - self.ui_agent_tools[t_name] = val + if imgui.begin_tab_bar("ContextTabs"): + if imgui.begin_tab_item("Projects")[0]: + self._render_projects_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Files")[0]: + self._render_files_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Screenshots")[0]: + self._render_screenshots_panel() + imgui.end_tab_item() + imgui.end_tab_bar() imgui.end() - # ---- Files - if self.show_windows["Files"]: - exp, self.show_windows["Files"] = imgui.begin("Files", self.show_windows["Files"]) + # ---- AI Settings Hub + if self.show_windows.get("AI Settings Hub", False): + exp, self.show_windows["AI Settings Hub"] = imgui.begin("AI Settings Hub", self.show_windows["AI Settings Hub"]) if exp: - imgui.text("Base Dir") - ch, self.ui_files_base_dir = imgui.input_text("##f_base", self.ui_files_base_dir) - imgui.same_line() - if imgui.button("Browse##fb"): - r = hide_tk_root() - d = filedialog.askdirectory() - r.destroy() - if d: self.ui_files_base_dir = d - - imgui.separator() - imgui.text("Paths") - imgui.begin_child("f_paths", imgui.ImVec2(0, -40), True) - for i, f in enumerate(self.files): - if imgui.button(f"x##f{i}"): - self.files.pop(i) - break - imgui.same_line() - imgui.text(f) - imgui.end_child() - - if imgui.button("Add File(s)"): - r = hide_tk_root() - paths = filedialog.askopenfilenames() - r.destroy() - for p in paths: - if p not in self.files: self.files.append(p) - imgui.same_line() - if imgui.button("Add Wildcard"): - r = hide_tk_root() - d = filedialog.askdirectory() - r.destroy() - if d: self.files.append(str(Path(d) / "**" / "*")) + if imgui.begin_tab_bar("AISettingsTabs"): + if imgui.begin_tab_item("Provider")[0]: + self._render_provider_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("System Prompts")[0]: + self._render_system_prompts_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Theme")[0]: + self._render_theme_panel() + imgui.end_tab_item() + imgui.end_tab_bar() imgui.end() - # ---- Screenshots - if self.show_windows["Screenshots"]: - exp, self.show_windows["Screenshots"] = imgui.begin("Screenshots", self.show_windows["Screenshots"]) + # ---- Discussion Hub + if self.show_windows.get("Discussion Hub", False): + exp, self.show_windows["Discussion Hub"] = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) if exp: - imgui.text("Base Dir") - ch, self.ui_shots_base_dir = imgui.input_text("##s_base", self.ui_shots_base_dir) - imgui.same_line() - if imgui.button("Browse##sb"): - r = hide_tk_root() - d = filedialog.askdirectory() - r.destroy() - if d: self.ui_shots_base_dir = d - - imgui.separator() - imgui.text("Paths") - imgui.begin_child("s_paths", imgui.ImVec2(0, -40), True) - for i, s in enumerate(self.screenshots): - if imgui.button(f"x##s{i}"): - self.screenshots.pop(i) - break - imgui.same_line() - imgui.text(s) - imgui.end_child() - - if imgui.button("Add Screenshot(s)"): - r = hide_tk_root() - paths = filedialog.askopenfilenames( - title="Select Screenshots", - filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")], - ) - r.destroy() - for p in paths: - if p not in self.screenshots: self.screenshots.append(p) + if imgui.begin_tab_bar("DiscussionTabs"): + if imgui.begin_tab_item("History")[0]: + self._render_discussion_panel() + imgui.end_tab_item() + imgui.end_tab_bar() imgui.end() - # ---- Discussion History - if self.show_windows["Discussion History"]: - exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"]) + # ---- Operations Hub + if self.show_windows.get("Operations Hub", False): + exp, self.show_windows["Operations Hub"] = imgui.begin("Operations Hub", self.show_windows["Operations Hub"]) if exp: - # THINKING indicator - is_thinking = self.ai_status in ["sending..."] - if is_thinking: - val = math.sin(time.time() * 10 * math.pi) - alpha = 1.0 if val > 0 else 0.0 - imgui.text_colored(imgui.ImVec4(1.0, 0.39, 0.39, alpha), "THINKING...") - imgui.separator() - - # Prior session viewing mode - if self.is_viewing_prior_session: - imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20)) - imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION") - imgui.same_line() - if imgui.button("Exit Prior Session"): - self.is_viewing_prior_session = False - self.prior_session_entries.clear() - imgui.separator() - imgui.begin_child("prior_scroll", imgui.ImVec2(0, 0), False) - for idx, entry in enumerate(self.prior_session_entries): - imgui.push_id(f"prior_{idx}") - kind = entry.get("kind", entry.get("type", "")) - imgui.text_colored(C_LBL, f"#{idx+1}") - imgui.same_line() - ts = entry.get("ts", entry.get("timestamp", "")) - if ts: - imgui.text_colored(vec4(160, 160, 160), str(ts)) - imgui.same_line() - imgui.text_colored(C_KEY, str(kind)) - payload = entry.get("payload", entry) - text = payload.get("text", payload.get("message", payload.get("content", ""))) - if text: - preview = str(text).replace("\n", " ")[:200] - if self.ui_word_wrap: - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(preview) - imgui.pop_text_wrap_pos() - else: - imgui.text(preview) - imgui.separator() - imgui.pop_id() - imgui.end_child() - imgui.pop_style_color() - - if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open): - names = self._get_discussion_names() - - if imgui.begin_combo("##disc_sel", self.active_discussion): - for name in names: - is_selected = (name == self.active_discussion) - if imgui.selectable(name, is_selected)[0]: - self._switch_discussion(name) - if is_selected: - imgui.set_item_default_focus() - imgui.end_combo() - - 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", "") - - imgui.text_colored(C_LBL, "commit:") - imgui.same_line() - imgui.text_colored(C_IN if git_commit else C_LBL, git_commit[:12] if git_commit else "(none)") - imgui.same_line() - if imgui.button("Update Commit"): - git_dir = self.ui_project_git_dir - if git_dir: - cmt = project_manager.get_git_commit(git_dir) - if cmt: - disc_data["git_commit"] = cmt - disc_data["last_updated"] = project_manager.now_ts() - self.ai_status = f"commit: {cmt[:12]}" - - imgui.text_colored(C_LBL, "updated:") - imgui.same_line() - imgui.text_colored(C_SUB, last_updated if last_updated else "(never)") - - ch, self.ui_disc_new_name_input = imgui.input_text("##new_disc", self.ui_disc_new_name_input) - imgui.same_line() - if imgui.button("Create"): - nm = self.ui_disc_new_name_input.strip() - if nm: self._create_discussion(nm); self.ui_disc_new_name_input = "" - imgui.same_line() - if imgui.button("Rename"): - nm = self.ui_disc_new_name_input.strip() - if nm: self._rename_discussion(self.active_discussion, nm); self.ui_disc_new_name_input = "" - imgui.same_line() - if imgui.button("Delete"): - self._delete_discussion(self.active_discussion) - - if not self.is_viewing_prior_session: - imgui.separator() - if imgui.button("+ Entry"): - self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()}) - imgui.same_line() - if imgui.button("-All"): - for e in self.disc_entries: e["collapsed"] = True - imgui.same_line() - if imgui.button("+All"): - for e in self.disc_entries: e["collapsed"] = False - imgui.same_line() - if imgui.button("Clear All"): - self.disc_entries.clear() - imgui.same_line() - if imgui.button("Save"): - self._flush_to_project() - self._save_active_project() - self._flush_to_config() - save_config(self.config) - self.ai_status = "discussion saved" - imgui.same_line() - if imgui.button("Load Log"): - self.cb_load_prior_log() - - ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history) - - # Truncation controls - imgui.text("Keep Pairs:") - imgui.same_line() - imgui.set_next_item_width(80) - ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1) - if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1 - imgui.same_line() - if imgui.button("Truncate"): - self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs) - self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs" - imgui.separator() - - if imgui.collapsing_header("Roles"): - imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True) - for i, r in enumerate(self.disc_roles): - if imgui.button(f"x##r{i}"): - self.disc_roles.pop(i) - break - imgui.same_line() - imgui.text(r) - imgui.end_child() - ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input) - imgui.same_line() - if imgui.button("Add"): - r = self.ui_disc_new_role_input.strip() - if r and r not in self.disc_roles: - self.disc_roles.append(r) - self.ui_disc_new_role_input = "" - - imgui.separator() - imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False) - for i, entry in enumerate(self.disc_entries): - imgui.push_id(str(i)) - collapsed = entry.get("collapsed", False) - read_mode = entry.get("read_mode", False) - - if imgui.button("+" if collapsed else "-"): - entry["collapsed"] = not collapsed - imgui.same_line() - - imgui.set_next_item_width(120) - if imgui.begin_combo("##role", entry["role"]): - for r in self.disc_roles: - if imgui.selectable(r, r == entry["role"])[0]: - entry["role"] = r - imgui.end_combo() - - if not collapsed: - imgui.same_line() - if imgui.button("[Edit]" if read_mode else "[Read]"): - entry["read_mode"] = not read_mode - - ts_str = entry.get("ts", "") - if ts_str: - imgui.same_line() - imgui.text_colored(vec4(120, 120, 100), ts_str) - - if collapsed: - imgui.same_line() - if imgui.button("Ins"): - self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()}) - imgui.same_line() - self._render_text_viewer(f"Entry #{i+1}", entry["content"]) - imgui.same_line() - if imgui.button("Del"): - self.disc_entries.pop(i) - imgui.pop_id() - break - imgui.same_line() - preview = entry["content"].replace("\n", " ")[:60] - if len(entry["content"]) > 60: preview += "..." - imgui.text_colored(vec4(160, 160, 150), preview) - - if not collapsed: - if read_mode: - imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) - if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(entry["content"]) - if self.ui_word_wrap: imgui.pop_text_wrap_pos() - imgui.end_child() - else: - ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150)) - - imgui.separator() - imgui.pop_id() - if self._scroll_disc_to_bottom: - imgui.set_scroll_here_y(1.0) - self._scroll_disc_to_bottom = False - imgui.end_child() + if imgui.begin_tab_bar("OperationsTabs"): + if imgui.begin_tab_item("Message")[0]: + self._render_message_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Response")[0]: + self._render_response_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Tool Calls")[0]: + self._render_tool_calls_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Comms History")[0]: + self._render_comms_history_panel() + imgui.end_tab_item() + imgui.end_tab_bar() imgui.end() - - # ---- Provider - if self.show_windows["Provider"]: - exp, self.show_windows["Provider"] = imgui.begin("Provider", self.show_windows["Provider"]) - if exp: - imgui.text("Provider") - if imgui.begin_combo("##prov", self.current_provider): - for p in PROVIDERS: - if imgui.selectable(p, p == self.current_provider)[0]: - self.current_provider = p - ai_client.reset_session() - ai_client.set_provider(p, self.current_model) - self.available_models = [] - self._fetch_models(p) - imgui.end_combo() - imgui.separator() - imgui.text("Model") - imgui.same_line() - if imgui.button("Fetch Models"): - self._fetch_models(self.current_provider) - - if imgui.begin_list_box("##models", imgui.ImVec2(-1, 120)): - for m in self.available_models: - if imgui.selectable(m, m == self.current_model)[0]: - self.current_model = m - ai_client.reset_session() - ai_client.set_provider(self.current_provider, m) - imgui.end_list_box() - imgui.separator() - imgui.text("Parameters") - ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f") - ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024) - ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024) - - imgui.separator() - imgui.text("Telemetry") - usage = self.session_usage - total = usage["input_tokens"] + usage["output_tokens"] - imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})") - if usage["cache_read_input_tokens"]: - imgui.text_colored(C_LBL, f" Cache Read: {usage['cache_read_input_tokens']:,} Creation: {usage['cache_creation_input_tokens']:,}") - imgui.text("Token Budget:") - imgui.progress_bar(self._token_budget_pct, imgui.ImVec2(-1, 0), f"{self._token_budget_current:,} / {self._token_budget_limit:,}") - if self._gemini_cache_text: - imgui.text_colored(C_SUB, self._gemini_cache_text) - imgui.end() - - # ---- Message - if self.show_windows["Message"]: - exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"]) - if exp: - # LIVE indicator - is_live = self.ai_status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."] - if is_live: - val = math.sin(time.time() * 10 * math.pi) - alpha = 1.0 if val > 0 else 0.0 - imgui.text_colored(imgui.ImVec4(0.39, 1.0, 0.39, alpha), "LIVE") - imgui.separator() - - ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40)) - - # Keyboard shortcuts - io = imgui.get_io() - ctrl_enter = io.key_ctrl and imgui.is_key_pressed(imgui.Key.enter) - ctrl_l = io.key_ctrl and imgui.is_key_pressed(imgui.Key.l) - if ctrl_l: - self.ui_ai_input = "" - - imgui.separator() - send_busy = False - with self._send_thread_lock: - if self.send_thread and self.send_thread.is_alive(): - send_busy = True - if imgui.button("Gen + Send") or ctrl_enter: - if not send_busy: - try: - md, path, file_items, stable_md, disc_text = self._do_generate() - self.last_md = md - self.last_md_path = path - self.last_file_items = file_items - except Exception as e: - self.ai_status = f"generate error: {e}" - else: - self.ai_status = "sending..." - user_msg = self.ui_ai_input - base_dir = self.ui_files_base_dir - csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) - ai_client.set_custom_system_prompt("\n\n".join(csp)) - ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit) - ai_client.set_agent_tools(self.ui_agent_tools) - send_md = stable_md - send_disc = disc_text - - def do_send(): - if self.ui_auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()}) - try: - resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc) - self.ai_response = resp - self.ai_status = "done" - self._trigger_blink = True - if self.ui_auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({"role": "AI", "content": resp, "collapsed": False, "ts": project_manager.now_ts()}) - except ProviderError as e: - self.ai_response = e.ui_message() - self.ai_status = "error" - self._trigger_blink = True - if self.ui_auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({"role": "Vendor API", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) - except Exception as e: - self.ai_response = f"ERROR: {e}" - self.ai_status = "error" - self._trigger_blink = True - if self.ui_auto_add_history: - with self._pending_history_adds_lock: - self._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) - - with self._send_thread_lock: - self.send_thread = threading.Thread(target=do_send, daemon=True) - self.send_thread.start() - imgui.same_line() - if imgui.button("MD Only"): - try: - md, path, *_ = self._do_generate() - self.last_md = md - self.last_md_path = path - self.ai_status = f"md written: {path.name}" - except Exception as e: - self.ai_status = f"error: {e}" - imgui.same_line() - if imgui.button("Reset"): - ai_client.reset_session() - ai_client.clear_comms_log() - self._tool_log.clear() - self._comms_log.clear() - self.ai_status = "session reset" - self.ai_response = "" - imgui.same_line() - if imgui.button("-> History"): - if self.ui_ai_input: - self.disc_entries.append({"role": "User", "content": self.ui_ai_input, "collapsed": False, "ts": project_manager.now_ts()}) - imgui.end() - - # ---- Response - if self.show_windows["Response"]: - - if self._trigger_blink: - self._trigger_blink = False - self._is_blinking = True - self._blink_start_time = time.time() - imgui.set_window_focus_str("Response") - - if self._is_blinking: - elapsed = time.time() - self._blink_start_time - if elapsed > 1.5: - self._is_blinking = False - else: - val = math.sin(elapsed * 8 * math.pi) - alpha = 50/255 if val > 0 else 0 - imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 255, 0, alpha)) - imgui.push_style_color(imgui.Col_.child_bg, vec4(0, 255, 0, alpha)) - - exp, self.show_windows["Response"] = imgui.begin("Response", self.show_windows["Response"]) - if exp: - if self.ui_word_wrap: - imgui.begin_child("resp_wrap", imgui.ImVec2(-1, -40), True) - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(self.ai_response) - imgui.pop_text_wrap_pos() - imgui.end_child() - else: - imgui.input_text_multiline("##ai_out", self.ai_response, imgui.ImVec2(-1, -40), imgui.InputTextFlags_.read_only) - imgui.separator() - if imgui.button("-> History"): - if self.ai_response: - self.disc_entries.append({"role": "AI", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) - - if self._is_blinking: - imgui.pop_style_color(2) - imgui.end() - - # ---- Tool Calls - if self.show_windows["Tool Calls"]: - exp, self.show_windows["Tool Calls"] = imgui.begin("Tool Calls", self.show_windows["Tool Calls"]) - if exp: - imgui.text("Tool call history") - imgui.same_line() - if imgui.button("Clear##tc"): - self._tool_log.clear() - imgui.separator() - imgui.begin_child("tc_scroll") - for i, (script, result) in enumerate(self._tool_log, 1): - first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)" - imgui.text_colored(C_KEY, f"Call #{i}: {first_line}") - imgui.same_line() - self._render_text_viewer(f"Call Script #{i}", script) - imgui.same_line() - self._render_text_viewer(f"Call Output #{i}", result) - - if self.ui_word_wrap: - imgui.begin_child(f"tc_wrap_{i}", imgui.ImVec2(-1, 72), True) - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(result) - imgui.pop_text_wrap_pos() - imgui.end_child() - else: - imgui.input_text_multiline(f"##tc_res_{i}", result, imgui.ImVec2(-1, 72), imgui.InputTextFlags_.read_only) - imgui.separator() - imgui.end_child() - imgui.end() - - # ---- Comms History - if self.show_windows["Comms History"]: - exp, self.show_windows["Comms History"] = imgui.begin("Comms History", self.show_windows["Comms History"]) - if exp: - imgui.text_colored(vec4(200, 220, 160), f"Status: {self.ai_status}") - imgui.same_line() - if imgui.button("Clear##comms"): - ai_client.clear_comms_log() - self._comms_log.clear() - imgui.separator() - - imgui.text_colored(C_OUT, "OUT") - imgui.same_line() - imgui.text_colored(C_REQ, "request") - imgui.same_line() - imgui.text_colored(C_TC, "tool_call") - imgui.same_line() - imgui.text(" ") - imgui.same_line() - imgui.text_colored(C_IN, "IN") - imgui.same_line() - imgui.text_colored(C_RES, "response") - imgui.same_line() - imgui.text_colored(C_TR, "tool_result") - imgui.separator() - - imgui.begin_child("comms_scroll", imgui.ImVec2(0, 0), False, imgui.WindowFlags_.horizontal_scrollbar) - for idx, entry in enumerate(self._comms_log, 1): - imgui.push_id(f"comms_{idx}") - d = entry["direction"] - k = entry["kind"] - - imgui.text_colored(vec4(160, 160, 160), f"#{idx}") - imgui.same_line() - imgui.text_colored(vec4(160, 160, 160), entry["ts"]) - imgui.same_line() - imgui.text_colored(DIR_COLORS.get(d, C_VAL), d) - imgui.same_line() - imgui.text_colored(KIND_COLORS.get(k, C_VAL), k) - imgui.same_line() - imgui.text_colored(C_LBL, f"{entry['provider']}/{entry['model']}") - - payload = entry["payload"] - - if k == "request": - self._render_heavy_text("message", payload.get("message", "")) - elif k == "response": - imgui.text_colored(C_LBL, "round:") - imgui.same_line() - imgui.text_colored(C_VAL, str(payload.get("round", ""))) - - imgui.text_colored(C_LBL, "stop_reason:") - imgui.same_line() - imgui.text_colored(vec4(255, 200, 120), str(payload.get("stop_reason", ""))) - - text = payload.get("text", "") - if text: - self._render_heavy_text("text", text) - - imgui.text_colored(C_LBL, "tool_calls:") - tcs = payload.get("tool_calls", []) - if not tcs: - imgui.text_colored(C_VAL, " (none)") - for i, tc in enumerate(tcs): - imgui.text_colored(C_KEY, f" call[{i}] {tc.get('name', '?')}") - if "id" in tc: - imgui.text_colored(C_LBL, " id:") - imgui.same_line() - imgui.text_colored(C_VAL, str(tc["id"])) - args = tc.get("args") or tc.get("input") or {} - if isinstance(args, dict): - for ak, av in args.items(): - self._render_heavy_text(f" {ak}", str(av)) - elif args: - self._render_heavy_text(" args", str(args)) - - usage = payload.get("usage") - if usage: - imgui.text_colored(C_SUB, "usage:") - for uk, uv in usage.items(): - imgui.text_colored(C_LBL, f" {uk.replace('_', ' ')}:") - imgui.same_line() - imgui.text_colored(C_NUM, str(uv)) - - elif k == "tool_call": - imgui.text_colored(C_LBL, "name:") - imgui.same_line() - imgui.text_colored(C_VAL, str(payload.get("name", ""))) - if "id" in payload: - imgui.text_colored(C_LBL, "id:") - imgui.same_line() - imgui.text_colored(C_VAL, str(payload["id"])) - if "script" in payload: - self._render_heavy_text("script", payload.get("script", "")) - elif "args" in payload: - args = payload["args"] - if isinstance(args, dict): - for ak, av in args.items(): - self._render_heavy_text(ak, str(av)) - else: - self._render_heavy_text("args", str(args)) - - elif k == "tool_result": - imgui.text_colored(C_LBL, "name:") - imgui.same_line() - imgui.text_colored(C_VAL, str(payload.get("name", ""))) - if "id" in payload: - imgui.text_colored(C_LBL, "id:") - imgui.same_line() - imgui.text_colored(C_VAL, str(payload["id"])) - self._render_heavy_text("output", payload.get("output", "")) - - elif k == "tool_result_send": - for i, r in enumerate(payload.get("results", [])): - imgui.text_colored(C_KEY, f"result[{i}]") - imgui.text_colored(C_LBL, " tool_use_id:") - imgui.same_line() - imgui.text_colored(C_VAL, str(r.get("tool_use_id", ""))) - self._render_heavy_text(" content", str(r.get("content", ""))) - else: - for key, val in payload.items(): - vstr = json.dumps(val, ensure_ascii=False, indent=2) if isinstance(val, (dict, list)) else str(val) - if key in HEAVY_KEYS: - self._render_heavy_text(key, vstr) - else: - imgui.text_colored(C_LBL, f"{key}:") - imgui.same_line() - imgui.text_colored(C_VAL, vstr) - - imgui.separator() - imgui.pop_id() - imgui.end_child() - imgui.end() - - # ---- System Prompts - if self.show_windows["System Prompts"]: - exp, self.show_windows["System Prompts"] = imgui.begin("System Prompts", self.show_windows["System Prompts"]) - if exp: - imgui.text("Global System Prompt (all projects)") - ch, self.ui_global_system_prompt = imgui.input_text_multiline("##gsp", self.ui_global_system_prompt, imgui.ImVec2(-1, 100)) - imgui.separator() - imgui.text("Project System Prompt") - ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100)) - imgui.end() - - # ---- Theme - if self.show_windows["Theme"]: - exp, self.show_windows["Theme"] = imgui.begin("Theme", self.show_windows["Theme"]) - if exp: - imgui.text("Palette") - cp = theme.get_current_palette() - if imgui.begin_combo("##pal", cp): - for p in theme.get_palette_names(): - if imgui.selectable(p, p == cp)[0]: - theme.apply(p) - imgui.end_combo() - imgui.separator() - imgui.text("Font") - imgui.push_item_width(-150) - ch, path = imgui.input_text("##fontp", theme.get_current_font_path()) - imgui.pop_item_width() - if ch: theme._current_font_path = path - imgui.same_line() - if imgui.button("Browse##font"): - r = hide_tk_root() - p = filedialog.askopenfilename(filetypes=[("Fonts", "*.ttf *.otf"), ("All", "*.*")]) - r.destroy() - if p: theme._current_font_path = p - - imgui.text("Size (px)") - imgui.same_line() - imgui.push_item_width(100) - ch, size = imgui.input_float("##fonts", theme.get_current_font_size(), 1.0, 1.0, "%.0f") - if ch: theme._current_font_size = size - imgui.pop_item_width() - imgui.same_line() - if imgui.button("Apply Font (Requires Restart)"): - self._flush_to_config() - save_config(self.config) - self.ai_status = "Font settings saved. Restart required." - - imgui.separator() - imgui.text("UI Scale (DPI)") - ch, scale = imgui.slider_float("##scale", theme.get_current_scale(), 0.5, 3.0, "%.2f") - if ch: theme.set_scale(scale) - imgui.end() - # ---- Diagnostics if self.show_windows["Diagnostics"]: exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"]) @@ -1632,6 +910,742 @@ class App: imgui.input_text_multiline("##tv_c", self.text_viewer_content, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) imgui.end() + def _render_projects_panel(self): + proj_name = self.project.get("project", {}).get("name", Path(self.active_project_path).stem) + imgui.text_colored(C_IN, f"Active: {proj_name}") + imgui.separator() + imgui.text("Git Directory") + ch, self.ui_project_git_dir = imgui.input_text("##git_dir", self.ui_project_git_dir) + imgui.same_line() + if imgui.button("Browse##git"): + r = hide_tk_root() + d = filedialog.askdirectory(title="Select Git Directory") + r.destroy() + if d: self.ui_project_git_dir = d + + imgui.separator() + imgui.text("Main Context File") + ch, self.ui_project_main_context = imgui.input_text("##main_ctx", self.ui_project_main_context) + imgui.same_line() + if imgui.button("Browse##ctx"): + r = hide_tk_root() + p = filedialog.askopenfilename(title="Select Main Context File") + r.destroy() + if p: self.ui_project_main_context = p + + imgui.separator() + imgui.text("Output Dir") + ch, self.ui_output_dir = imgui.input_text("##out_dir", self.ui_output_dir) + imgui.same_line() + if imgui.button("Browse##out"): + r = hide_tk_root() + d = filedialog.askdirectory(title="Select Output Dir") + r.destroy() + if d: self.ui_output_dir = d + + imgui.separator() + imgui.text("Project Files") + imgui.begin_child("proj_files", imgui.ImVec2(0, 150), True) + for i, pp in enumerate(self.project_paths): + is_active = (pp == self.active_project_path) + if imgui.button(f"x##p{i}"): + removed = self.project_paths.pop(i) + if removed == self.active_project_path and self.project_paths: + self._switch_project(self.project_paths[0]) + break + imgui.same_line() + marker = " *" if is_active else "" + if is_active: imgui.push_style_color(imgui.Col_.text, C_IN) + if imgui.button(f"{Path(pp).stem}{marker}##ps{i}"): + self._switch_project(pp) + if is_active: imgui.pop_style_color() + imgui.same_line() + imgui.text_colored(C_LBL, pp) + imgui.end_child() + + if imgui.button("Add Project"): + r = hide_tk_root() + p = filedialog.askopenfilename( + title="Select Project .toml", + filetypes=[("TOML", "*.toml"), ("All", "*.*")], + ) + r.destroy() + if p and p not in self.project_paths: + self.project_paths.append(p) + imgui.same_line() + if imgui.button("New Project"): + r = hide_tk_root() + p = filedialog.asksaveasfilename(title="Create New Project .toml", defaultextension=".toml", filetypes=[("TOML", "*.toml"), ("All", "*.*")]) + r.destroy() + if p: + 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) + imgui.same_line() + if imgui.button("Save All"): + self._flush_to_project() + self._save_active_project() + self._flush_to_config() + save_config(self.config) + self.ai_status = "config saved" + + ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap) + ch, self.ui_summary_only = imgui.checkbox("Summary Only (send file structure, not full content)", self.ui_summary_only) + + if imgui.collapsing_header("Agent Tools"): + for t_name in AGENT_TOOL_NAMES: + val = self.ui_agent_tools.get(t_name, True) + ch, val = imgui.checkbox(f"Enable {t_name}", val) + if ch: + self.ui_agent_tools[t_name] = val + + def _render_files_panel(self): + imgui.text("Base Dir") + ch, self.ui_files_base_dir = imgui.input_text("##f_base", self.ui_files_base_dir) + imgui.same_line() + if imgui.button("Browse##fb"): + r = hide_tk_root() + d = filedialog.askdirectory() + r.destroy() + if d: self.ui_files_base_dir = d + + imgui.separator() + imgui.text("Paths") + imgui.begin_child("f_paths", imgui.ImVec2(0, -40), True) + for i, f in enumerate(self.files): + if imgui.button(f"x##f{i}"): + self.files.pop(i) + break + imgui.same_line() + imgui.text(f) + imgui.end_child() + + if imgui.button("Add File(s)"): + r = hide_tk_root() + paths = filedialog.askopenfilenames() + r.destroy() + for p in paths: + if p not in self.files: self.files.append(p) + imgui.same_line() + if imgui.button("Add Wildcard"): + r = hide_tk_root() + d = filedialog.askdirectory() + r.destroy() + if d: self.files.append(str(Path(d) / "**" / "*")) + + def _render_screenshots_panel(self): + imgui.text("Base Dir") + ch, self.ui_shots_base_dir = imgui.input_text("##s_base", self.ui_shots_base_dir) + imgui.same_line() + if imgui.button("Browse##sb"): + r = hide_tk_root() + d = filedialog.askdirectory() + r.destroy() + if d: self.ui_shots_base_dir = d + + imgui.separator() + imgui.text("Paths") + imgui.begin_child("s_paths", imgui.ImVec2(0, -40), True) + for i, s in enumerate(self.screenshots): + if imgui.button(f"x##s{i}"): + self.screenshots.pop(i) + break + imgui.same_line() + imgui.text(s) + imgui.end_child() + + if imgui.button("Add Screenshot(s)"): + r = hide_tk_root() + paths = filedialog.askopenfilenames( + title="Select Screenshots", + filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")], + ) + r.destroy() + for p in paths: + if p not in self.screenshots: self.screenshots.append(p) + + def _render_discussion_panel(self): + # THINKING indicator + is_thinking = self.ai_status in ["sending..."] + if is_thinking: + val = math.sin(time.time() * 10 * math.pi) + alpha = 1.0 if val > 0 else 0.0 + imgui.text_colored(imgui.ImVec4(1.0, 0.39, 0.39, alpha), "THINKING...") + imgui.separator() + + # Prior session viewing mode + if self.is_viewing_prior_session: + imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20)) + imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION") + imgui.same_line() + if imgui.button("Exit Prior Session"): + self.is_viewing_prior_session = False + self.prior_session_entries.clear() + imgui.separator() + imgui.begin_child("prior_scroll", imgui.ImVec2(0, 0), False) + for idx, entry in enumerate(self.prior_session_entries): + imgui.push_id(f"prior_{idx}") + kind = entry.get("kind", entry.get("type", "")) + imgui.text_colored(C_LBL, f"#{idx+1}") + imgui.same_line() + ts = entry.get("ts", entry.get("timestamp", "")) + if ts: + imgui.text_colored(vec4(160, 160, 160), str(ts)) + imgui.same_line() + imgui.text_colored(C_KEY, str(kind)) + payload = entry.get("payload", entry) + text = payload.get("text", payload.get("message", payload.get("content", ""))) + if text: + preview = str(text).replace("\n", " ")[:200] + if self.ui_word_wrap: + imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + imgui.text(preview) + imgui.pop_text_wrap_pos() + else: + imgui.text(preview) + imgui.separator() + imgui.pop_id() + imgui.end_child() + imgui.pop_style_color() + + if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open): + names = self._get_discussion_names() + + if imgui.begin_combo("##disc_sel", self.active_discussion): + for name in names: + is_selected = (name == self.active_discussion) + if imgui.selectable(name, is_selected)[0]: + self._switch_discussion(name) + if is_selected: + imgui.set_item_default_focus() + imgui.end_combo() + + 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", "") + + imgui.text_colored(C_LBL, "commit:") + imgui.same_line() + imgui.text_colored(C_IN if git_commit else C_LBL, git_commit[:12] if git_commit else "(none)") + imgui.same_line() + if imgui.button("Update Commit"): + git_dir = self.ui_project_git_dir + if git_dir: + cmt = project_manager.get_git_commit(git_dir) + if cmt: + disc_data["git_commit"] = cmt + disc_data["last_updated"] = project_manager.now_ts() + self.ai_status = f"commit: {cmt[:12]}" + + imgui.text_colored(C_LBL, "updated:") + imgui.same_line() + imgui.text_colored(C_SUB, last_updated if last_updated else "(never)") + + ch, self.ui_disc_new_name_input = imgui.input_text("##new_disc", self.ui_disc_new_name_input) + imgui.same_line() + if imgui.button("Create"): + nm = self.ui_disc_new_name_input.strip() + if nm: self._create_discussion(nm); self.ui_disc_new_name_input = "" + imgui.same_line() + if imgui.button("Rename"): + nm = self.ui_disc_new_name_input.strip() + if nm: self._rename_discussion(self.active_discussion, nm); self.ui_disc_new_name_input = "" + imgui.same_line() + if imgui.button("Delete"): + self._delete_discussion(self.active_discussion) + + if not self.is_viewing_prior_session: + imgui.separator() + if imgui.button("+ Entry"): + self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()}) + imgui.same_line() + if imgui.button("-All"): + for e in self.disc_entries: e["collapsed"] = True + imgui.same_line() + if imgui.button("+All"): + for e in self.disc_entries: e["collapsed"] = False + imgui.same_line() + if imgui.button("Clear All"): + self.disc_entries.clear() + imgui.same_line() + if imgui.button("Save"): + self._flush_to_project() + self._save_active_project() + self._flush_to_config() + save_config(self.config) + self.ai_status = "discussion saved" + imgui.same_line() + if imgui.button("Load Log"): + self.cb_load_prior_log() + + ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history) + + # Truncation controls + imgui.text("Keep Pairs:") + imgui.same_line() + imgui.set_next_item_width(80) + ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1) + if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1 + imgui.same_line() + if imgui.button("Truncate"): + self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs) + self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs" + imgui.separator() + + if imgui.collapsing_header("Roles"): + imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True) + for i, r in enumerate(self.disc_roles): + if imgui.button(f"x##r{i}"): + self.disc_roles.pop(i) + break + imgui.same_line() + imgui.text(r) + imgui.end_child() + ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input) + imgui.same_line() + if imgui.button("Add"): + r = self.ui_disc_new_role_input.strip() + if r and r not in self.disc_roles: + self.disc_roles.append(r) + self.ui_disc_new_role_input = "" + + imgui.separator() + imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False) + for i, entry in enumerate(self.disc_entries): + imgui.push_id(str(i)) + collapsed = entry.get("collapsed", False) + read_mode = entry.get("read_mode", False) + + if imgui.button("+" if collapsed else "-"): + entry["collapsed"] = not collapsed + imgui.same_line() + + imgui.set_next_item_width(120) + if imgui.begin_combo("##role", entry["role"]): + for r in self.disc_roles: + if imgui.selectable(r, r == entry["role"])[0]: + entry["role"] = r + imgui.end_combo() + + if not collapsed: + imgui.same_line() + if imgui.button("[Edit]" if read_mode else "[Read]"): + entry["read_mode"] = not read_mode + + ts_str = entry.get("ts", "") + if ts_str: + imgui.same_line() + imgui.text_colored(vec4(120, 120, 100), ts_str) + + if collapsed: + imgui.same_line() + if imgui.button("Ins"): + self.disc_entries.insert(i, {"role": "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()}) + imgui.same_line() + self._render_text_viewer(f"Entry #{i+1}", entry["content"]) + imgui.same_line() + if imgui.button("Del"): + self.disc_entries.pop(i) + imgui.pop_id() + break + imgui.same_line() + preview = entry["content"].replace("\n", " ")[:60] + if len(entry["content"]) > 60: preview += "..." + imgui.text_colored(vec4(160, 160, 150), preview) + + if not collapsed: + if read_mode: + imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) + if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + imgui.text(entry["content"]) + if self.ui_word_wrap: imgui.pop_text_wrap_pos() + imgui.end_child() + else: + ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150)) + + imgui.separator() + imgui.pop_id() + if self._scroll_disc_to_bottom: + imgui.set_scroll_here_y(1.0) + self._scroll_disc_to_bottom = False + imgui.end_child() + + def _render_provider_panel(self): + imgui.text("Provider") + if imgui.begin_combo("##prov", self.current_provider): + for p in PROVIDERS: + if imgui.selectable(p, p == self.current_provider)[0]: + self.current_provider = p + ai_client.reset_session() + ai_client.set_provider(p, self.current_model) + self.available_models = [] + self._fetch_models(p) + imgui.end_combo() + imgui.separator() + imgui.text("Model") + imgui.same_line() + if imgui.button("Fetch Models"): + self._fetch_models(self.current_provider) + + if imgui.begin_list_box("##models", imgui.ImVec2(-1, 120)): + for m in self.available_models: + if imgui.selectable(m, m == self.current_model)[0]: + self.current_model = m + ai_client.reset_session() + ai_client.set_provider(self.current_provider, m) + imgui.end_list_box() + imgui.separator() + imgui.text("Parameters") + ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f") + ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024) + ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024) + + imgui.separator() + imgui.text("Telemetry") + usage = self.session_usage + total = usage["input_tokens"] + usage["output_tokens"] + imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})") + if usage["cache_read_input_tokens"]: + imgui.text_colored(C_LBL, f" Cache Read: {usage['cache_read_input_tokens']:,} Creation: {usage['cache_creation_input_tokens']:,}") + imgui.text("Token Budget:") + imgui.progress_bar(self._token_budget_pct, imgui.ImVec2(-1, 0), f"{self._token_budget_current:,} / {self._token_budget_limit:,}") + if self._gemini_cache_text: + imgui.text_colored(C_SUB, self._gemini_cache_text) + + def _render_message_panel(self): + # LIVE indicator + is_live = self.ai_status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."] + if is_live: + val = math.sin(time.time() * 10 * math.pi) + alpha = 1.0 if val > 0 else 0.0 + imgui.text_colored(imgui.ImVec4(0.39, 1.0, 0.39, alpha), "LIVE") + imgui.separator() + + ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40)) + + # Keyboard shortcuts + io = imgui.get_io() + ctrl_enter = io.key_ctrl and imgui.is_key_pressed(imgui.Key.enter) + ctrl_l = io.key_ctrl and imgui.is_key_pressed(imgui.Key.l) + if ctrl_l: + self.ui_ai_input = "" + + imgui.separator() + send_busy = False + with self._send_thread_lock: + if self.send_thread and self.send_thread.is_alive(): + send_busy = True + if imgui.button("Gen + Send") or ctrl_enter: + if not send_busy: + try: + md, path, file_items, stable_md, disc_text = self._do_generate() + self.last_md = md + self.last_md_path = path + self.last_file_items = file_items + except Exception as e: + self.ai_status = f"generate error: {e}" + else: + self.ai_status = "sending..." + user_msg = self.ui_ai_input + base_dir = self.ui_files_base_dir + csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) + ai_client.set_custom_system_prompt("\n\n".join(csp)) + ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit) + ai_client.set_agent_tools(self.ui_agent_tools) + send_md = stable_md + send_disc = disc_text + + def do_send(): + if self.ui_auto_add_history: + with self._pending_history_adds_lock: + self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()}) + try: + resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc) + self.ai_response = resp + self.ai_status = "done" + self._trigger_blink = True + if self.ui_auto_add_history: + with self._pending_history_adds_lock: + self._pending_history_adds.append({"role": "AI", "content": resp, "collapsed": False, "ts": project_manager.now_ts()}) + except ProviderError as e: + self.ai_response = e.ui_message() + self.ai_status = "error" + self._trigger_blink = True + if self.ui_auto_add_history: + with self._pending_history_adds_lock: + self._pending_history_adds.append({"role": "Vendor API", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) + except Exception as e: + self.ai_response = f"ERROR: {e}" + self.ai_status = "error" + self._trigger_blink = True + if self.ui_auto_add_history: + with self._pending_history_adds_lock: + self._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) + + with self._send_thread_lock: + self.send_thread = threading.Thread(target=do_send, daemon=True) + self.send_thread.start() + imgui.same_line() + if imgui.button("MD Only"): + try: + md, path, *_ = self._do_generate() + self.last_md = md + self.last_md_path = path + self.ai_status = f"md written: {path.name}" + except Exception as e: + self.ai_status = f"error: {e}" + imgui.same_line() + if imgui.button("Reset"): + ai_client.reset_session() + ai_client.clear_comms_log() + self._tool_log.clear() + self._comms_log.clear() + self.ai_status = "session reset" + self.ai_response = "" + imgui.same_line() + if imgui.button("-> History"): + if self.ui_ai_input: + self.disc_entries.append({"role": "User", "content": self.ui_ai_input, "collapsed": False, "ts": project_manager.now_ts()}) + + def _render_response_panel(self): + + if self._trigger_blink: + self._trigger_blink = False + self._is_blinking = True + self._blink_start_time = time.time() + imgui.set_window_focus_str("Response") + + if self._is_blinking: + elapsed = time.time() - self._blink_start_time + if elapsed > 1.5: + self._is_blinking = False + else: + val = math.sin(elapsed * 8 * math.pi) + alpha = 50/255 if val > 0 else 0 + imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 255, 0, alpha)) + imgui.push_style_color(imgui.Col_.child_bg, vec4(0, 255, 0, alpha)) + + if self.ui_word_wrap: + imgui.begin_child("resp_wrap", imgui.ImVec2(-1, -40), True) + imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + imgui.text(self.ai_response) + imgui.pop_text_wrap_pos() + imgui.end_child() + else: + imgui.input_text_multiline("##ai_out", self.ai_response, imgui.ImVec2(-1, -40), imgui.InputTextFlags_.read_only) + imgui.separator() + if imgui.button("-> History"): + if self.ai_response: + self.disc_entries.append({"role": "AI", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) + + if self._is_blinking: + imgui.pop_style_color(2) + + def _render_tool_calls_panel(self): + imgui.text("Tool call history") + imgui.same_line() + if imgui.button("Clear##tc"): + self._tool_log.clear() + imgui.separator() + imgui.begin_child("tc_scroll") + for i, (script, result) in enumerate(self._tool_log, 1): + first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)" + imgui.text_colored(C_KEY, f"Call #{i}: {first_line}") + imgui.same_line() + self._render_text_viewer(f"Call Script #{i}", script) + imgui.same_line() + self._render_text_viewer(f"Call Output #{i}", result) + + if self.ui_word_wrap: + imgui.begin_child(f"tc_wrap_{i}", imgui.ImVec2(-1, 72), True) + imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + imgui.text(result) + imgui.pop_text_wrap_pos() + imgui.end_child() + else: + imgui.input_text_multiline(f"##tc_res_{i}", result, imgui.ImVec2(-1, 72), imgui.InputTextFlags_.read_only) + imgui.separator() + imgui.end_child() + + def _render_comms_history_panel(self): + imgui.text_colored(vec4(200, 220, 160), f"Status: {self.ai_status}") + imgui.same_line() + if imgui.button("Clear##comms"): + ai_client.clear_comms_log() + self._comms_log.clear() + imgui.separator() + + imgui.text_colored(C_OUT, "OUT") + imgui.same_line() + imgui.text_colored(C_REQ, "request") + imgui.same_line() + imgui.text_colored(C_TC, "tool_call") + imgui.same_line() + imgui.text(" ") + imgui.same_line() + imgui.text_colored(C_IN, "IN") + imgui.same_line() + imgui.text_colored(C_RES, "response") + imgui.same_line() + imgui.text_colored(C_TR, "tool_result") + imgui.separator() + + imgui.begin_child("comms_scroll", imgui.ImVec2(0, 0), False, imgui.WindowFlags_.horizontal_scrollbar) + for idx, entry in enumerate(self._comms_log, 1): + imgui.push_id(f"comms_{idx}") + d = entry["direction"] + k = entry["kind"] + + imgui.text_colored(vec4(160, 160, 160), f"#{idx}") + imgui.same_line() + imgui.text_colored(vec4(160, 160, 160), entry["ts"]) + imgui.same_line() + imgui.text_colored(DIR_COLORS.get(d, C_VAL), d) + imgui.same_line() + imgui.text_colored(KIND_COLORS.get(k, C_VAL), k) + imgui.same_line() + imgui.text_colored(C_LBL, f"{entry['provider']}/{entry['model']}") + + payload = entry["payload"] + + if k == "request": + self._render_heavy_text("message", payload.get("message", "")) + elif k == "response": + imgui.text_colored(C_LBL, "round:") + imgui.same_line() + imgui.text_colored(C_VAL, str(payload.get("round", ""))) + + imgui.text_colored(C_LBL, "stop_reason:") + imgui.same_line() + imgui.text_colored(vec4(255, 200, 120), str(payload.get("stop_reason", ""))) + + text = payload.get("text", "") + if text: + self._render_heavy_text("text", text) + + imgui.text_colored(C_LBL, "tool_calls:") + tcs = payload.get("tool_calls", []) + if not tcs: + imgui.text_colored(C_VAL, " (none)") + for i, tc in enumerate(tcs): + imgui.text_colored(C_KEY, f" call[{i}] {tc.get('name', '?')}") + if "id" in tc: + imgui.text_colored(C_LBL, " id:") + imgui.same_line() + imgui.text_colored(C_VAL, str(tc["id"])) + args = tc.get("args") or tc.get("input") or {} + if isinstance(args, dict): + for ak, av in args.items(): + self._render_heavy_text(f" {ak}", str(av)) + elif args: + self._render_heavy_text(" args", str(args)) + + usage = payload.get("usage") + if usage: + imgui.text_colored(C_SUB, "usage:") + for uk, uv in usage.items(): + imgui.text_colored(C_LBL, f" {uk.replace('_', ' ')}:") + imgui.same_line() + imgui.text_colored(C_NUM, str(uv)) + + elif k == "tool_call": + imgui.text_colored(C_LBL, "name:") + imgui.same_line() + imgui.text_colored(C_VAL, str(payload.get("name", ""))) + if "id" in payload: + imgui.text_colored(C_LBL, "id:") + imgui.same_line() + imgui.text_colored(C_VAL, str(payload["id"])) + if "script" in payload: + self._render_heavy_text("script", payload.get("script", "")) + elif "args" in payload: + args = payload["args"] + if isinstance(args, dict): + for ak, av in args.items(): + self._render_heavy_text(ak, str(av)) + else: + self._render_heavy_text("args", str(args)) + + elif k == "tool_result": + imgui.text_colored(C_LBL, "name:") + imgui.same_line() + imgui.text_colored(C_VAL, str(payload.get("name", ""))) + if "id" in payload: + imgui.text_colored(C_LBL, "id:") + imgui.same_line() + imgui.text_colored(C_VAL, str(payload["id"])) + self._render_heavy_text("output", payload.get("output", "")) + + elif k == "tool_result_send": + for i, r in enumerate(payload.get("results", [])): + imgui.text_colored(C_KEY, f"result[{i}]") + imgui.text_colored(C_LBL, " tool_use_id:") + imgui.same_line() + imgui.text_colored(C_VAL, str(r.get("tool_use_id", ""))) + self._render_heavy_text(" content", str(r.get("content", ""))) + else: + for key, val in payload.items(): + vstr = json.dumps(val, ensure_ascii=False, indent=2) if isinstance(val, (dict, list)) else str(val) + if key in HEAVY_KEYS: + self._render_heavy_text(key, vstr) + else: + imgui.text_colored(C_LBL, f"{key}:") + imgui.same_line() + imgui.text_colored(C_VAL, vstr) + + imgui.separator() + imgui.pop_id() + imgui.end_child() + + def _render_system_prompts_panel(self): + imgui.text("Global System Prompt (all projects)") + ch, self.ui_global_system_prompt = imgui.input_text_multiline("##gsp", self.ui_global_system_prompt, imgui.ImVec2(-1, 100)) + imgui.separator() + imgui.text("Project System Prompt") + ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100)) + + def _render_theme_panel(self): + imgui.text("Palette") + cp = theme.get_current_palette() + if imgui.begin_combo("##pal", cp): + for p in theme.get_palette_names(): + if imgui.selectable(p, p == cp)[0]: + theme.apply(p) + imgui.end_combo() + imgui.separator() + imgui.text("Font") + imgui.push_item_width(-150) + ch, path = imgui.input_text("##fontp", theme.get_current_font_path()) + imgui.pop_item_width() + if ch: theme._current_font_path = path + imgui.same_line() + if imgui.button("Browse##font"): + r = hide_tk_root() + p = filedialog.askopenfilename(filetypes=[("Fonts", "*.ttf *.otf"), ("All", "*.*")]) + r.destroy() + if p: theme._current_font_path = p + + imgui.text("Size (px)") + imgui.same_line() + imgui.push_item_width(100) + ch, size = imgui.input_float("##fonts", theme.get_current_font_size(), 1.0, 1.0, "%.0f") + if ch: theme._current_font_size = size + imgui.pop_item_width() + imgui.same_line() + if imgui.button("Apply Font (Requires Restart)"): + self._flush_to_config() + save_config(self.config) + self.ai_status = "Font settings saved. Restart required." + + imgui.separator() + imgui.text("UI Scale (DPI)") + ch, scale = imgui.slider_float("##scale", theme.get_current_scale(), 0.5, 3.0, "%.2f") + if ch: theme.set_scale(scale) + def _load_fonts(self): font_path, font_size = theme.get_font_loading_params() if font_path and Path(font_path).exists(): diff --git a/refactor_gui2.py b/refactor_gui2.py new file mode 100644 index 0000000..a9964e7 --- /dev/null +++ b/refactor_gui2.py @@ -0,0 +1,243 @@ +import re +import sys + +def main(): + with open("gui_2.py", "r", encoding="utf-8") as f: + content = f.read() + + # 1. Update _default_windows dictionary + old_default = """ _default_windows = { + "Projects": True, + "Files": True, + "Screenshots": True, + "Discussion History": True, + "Provider": True, + "Message": True, + "Response": True, + "Tool Calls": True, + "Comms History": True, + "System Prompts": True, + "Theme": True, + "Diagnostics": False, + }""" + new_default = """ _default_windows = { + "Context Hub": True, + "AI Settings Hub": True, + "Discussion Hub": True, + "Operations Hub": True, + "Diagnostics": False, + }""" + if old_default in content: + content = content.replace(old_default, new_default) + else: + print("Could not find _default_windows block") + + # 2. Extract panels into methods + panels = { + "Projects": "_render_projects_panel", + "Files": "_render_files_panel", + "Screenshots": "_render_screenshots_panel", + "Discussion History": "_render_discussion_panel", + "Provider": "_render_provider_panel", + "Message": "_render_message_panel", + "Response": "_render_response_panel", + "Tool Calls": "_render_tool_calls_panel", + "Comms History": "_render_comms_history_panel", + "System Prompts": "_render_system_prompts_panel", + "Theme": "_render_theme_panel", + } + + methods = [] + + # We will search for: + # # ---- PanelName + # if self.show_windows["PanelName"]: + # ... (until imgui.end()) + + for panel_name, method_name in panels.items(): + # Build a regex to match the entire panel block + # We need to capture from the comment to the corresponding imgui.end() + # This requires matching balanced indentation or looking for specific end tokens. + # Since each block ends with ` imgui.end()\n`, we can use that. + # But wait, some panels like 'Response' might have different structures. + + # A simpler way: split the file by `# ---- ` comments. + pass + + # Actually, the safest way is to replace the whole `_gui_func` body from `# ---- Projects` down to just before `# ---- Diagnostics`. + start_marker = " # ---- Projects" + end_marker = " # ---- Diagnostics" + + start_idx = content.find(start_marker) + end_idx = content.find(end_marker) + + if start_idx == -1 or end_idx == -1: + print("Markers not found!") + sys.exit(1) + + panels_text = content[start_idx:end_idx] + + # Now split panels_text by `# ---- ` + panel_chunks = panels_text.split(" # ---- ") + + methods_code = "" + for chunk in panel_chunks: + if not chunk.strip(): continue + + # Find the panel name (first line) + lines = chunk.split('\n') + name = lines[0].strip() + + if name not in panels: + continue + + method_name = panels[name] + + # The rest of the lines are the panel logic. + # We need to remove the `if self.show_windows["..."]:` check and the `imgui.begin()`/`imgui.end()` calls. + # But wait! For ImGui, when we move them to child tabs, we DON'T want `imgui.begin` and `imgui.end`. + # We just want the contents inside `if exp:` + # This is critical! A tab item acts as the container. + + # Let's extract everything inside `if exp:` or just before `imgui.begin()`. + + # Find the line with `imgui.begin(` + begin_line_idx = -1 + end_line_idx = -1 + + for i, line in enumerate(lines): + if "imgui.begin(" in line: + begin_line_idx = i + elif "imgui.end()" in line: + end_line_idx = i + + if begin_line_idx == -1 or end_line_idx == -1: + print(f"Could not parse begin/end for {name}") + continue + + # Lines before begin (e.g. blinking logic in Response) + pre_begin_lines = lines[2:begin_line_idx] # skipping `if self.show_windows...:` + + # Lines between `if exp:` and `imgui.end()` + # Usually it's ` if exp:\n ...` + # We need to check if line after begin is `if exp:` + exp_check_idx = begin_line_idx + 1 + content_lines = [] + if "if exp:" in lines[exp_check_idx] or "if expanded:" in lines[exp_check_idx]: + content_lines = lines[exp_check_idx+1:end_line_idx] + else: + content_lines = lines[begin_line_idx+1:end_line_idx] + + # Post end lines (e.g. pop_style_color in Response) + # Wait, the pop_style_color is BEFORE imgui.end() + # So it's already in content_lines. + + # Reconstruct the method body + method_body = [] + + for line in pre_begin_lines: + # unindent by 12 spaces (was under `if self.show_windows...`) + if line.startswith(" "): + method_body.append(line[12:]) + else: + method_body.append(line) + + for line in content_lines: + # unindent by 16 spaces (was under `if exp:`) + if line.startswith(" "): + method_body.append(line[8:]) + elif line.startswith(" "): + # like pop_style_color which is under `if show_windows` + method_body.append(line[12:]) + else: + method_body.append(line) + + methods_code += f" def {method_name}(self):\n" + for line in method_body: + methods_code += f" {line}\n" + methods_code += "\n" + + # Hub rendering code + hub_code = """ + # ---- Context Hub + if self.show_windows.get("Context Hub", False): + exp, self.show_windows["Context Hub"] = imgui.begin("Context Hub", self.show_windows["Context Hub"]) + if exp: + if imgui.begin_tab_bar("ContextTabs"): + if imgui.begin_tab_item("Projects")[0]: + self._render_projects_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Files")[0]: + self._render_files_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Screenshots")[0]: + self._render_screenshots_panel() + imgui.end_tab_item() + imgui.end_tab_bar() + imgui.end() + + # ---- AI Settings Hub + if self.show_windows.get("AI Settings Hub", False): + exp, self.show_windows["AI Settings Hub"] = imgui.begin("AI Settings Hub", self.show_windows["AI Settings Hub"]) + if exp: + if imgui.begin_tab_bar("AISettingsTabs"): + if imgui.begin_tab_item("Provider")[0]: + self._render_provider_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("System Prompts")[0]: + self._render_system_prompts_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Theme")[0]: + self._render_theme_panel() + imgui.end_tab_item() + imgui.end_tab_bar() + imgui.end() + + # ---- Discussion Hub + if self.show_windows.get("Discussion Hub", False): + exp, self.show_windows["Discussion Hub"] = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) + if exp: + if imgui.begin_tab_bar("DiscussionTabs"): + if imgui.begin_tab_item("History")[0]: + self._render_discussion_panel() + imgui.end_tab_item() + imgui.end_tab_bar() + imgui.end() + + # ---- Operations Hub + if self.show_windows.get("Operations Hub", False): + exp, self.show_windows["Operations Hub"] = imgui.begin("Operations Hub", self.show_windows["Operations Hub"]) + if exp: + if imgui.begin_tab_bar("OperationsTabs"): + if imgui.begin_tab_item("Message")[0]: + self._render_message_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Response")[0]: + self._render_response_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Tool Calls")[0]: + self._render_tool_calls_panel() + imgui.end_tab_item() + if imgui.begin_tab_item("Comms History")[0]: + self._render_comms_history_panel() + imgui.end_tab_item() + imgui.end_tab_bar() + imgui.end() +""" + + # Replace panels_text with hub_code + content = content[:start_idx] + hub_code + content[end_idx:] + + # Append methods_code to the end of the App class + # We find the end of the class by looking for `def _load_fonts(self):` + # and inserting methods_code right before it. + fonts_idx = content.find(" def _load_fonts(self):") + content = content[:fonts_idx] + methods_code + content[fonts_idx:] + + with open("gui_2.py", "w", encoding="utf-8") as f: + f.write(content) + + print("Refactoring complete.") + +if __name__ == "__main__": + main() diff --git a/tests/test_gui2_layout.py b/tests/test_gui2_layout.py new file mode 100644 index 0000000..1fa475b --- /dev/null +++ b/tests/test_gui2_layout.py @@ -0,0 +1,46 @@ +import pytest +from unittest.mock import patch +from gui_2 import App + +@pytest.fixture +def app_instance(): + with ( + patch('gui_2.load_config', return_value={'gui': {'show_windows': {}}}), + patch('gui_2.save_config'), + patch('gui_2.project_manager'), + patch('gui_2.session_logger'), + patch('gui_2.immapp.run'), + patch.object(App, '_load_active_project'), + patch.object(App, '_fetch_models'), + patch.object(App, '_load_fonts'), + patch.object(App, '_post_init') + ): + yield App() + +def test_gui2_hubs_exist_in_show_windows(app_instance): + """ + Verifies that the new consolidated Hub windows are defined in the App's show_windows. + This ensures they will be available in the 'Windows' menu. + """ + expected_hubs = [ + "Context Hub", + "AI Settings Hub", + "Discussion Hub", + "Operations Hub", + ] + + for hub in expected_hubs: + assert hub in app_instance.show_windows, f"Expected hub window '{hub}' not found in show_windows" + +def test_gui2_old_windows_removed_from_show_windows(app_instance): + """ + Verifies that the old fragmented windows are removed from show_windows. + """ + old_windows = [ + "Projects", "Files", "Screenshots", + "Provider", "System Prompts", + "Message", "Response", "Tool Calls", "Comms History" + ] + + for old_win in old_windows: + assert old_win not in app_instance.show_windows, f"Old window '{old_win}' should have been removed from show_windows"