diff --git a/gui_2.py b/gui_2.py index 5248f9c..ea06d4c 100644 --- a/gui_2.py +++ b/gui_2.py @@ -161,6 +161,16 @@ class App: """The main ImGui interface orchestrator for Manual Slop.""" def __init__(self) -> None: + # Initialize locks first to avoid initialization order issues + self._send_thread_lock = threading.Lock() + self._disc_entries_lock = threading.Lock() + self._pending_comms_lock = threading.Lock() + self._pending_tool_calls_lock = threading.Lock() + self._pending_history_adds_lock = threading.Lock() + self._pending_gui_tasks_lock = threading.Lock() + self._pending_dialog_lock = threading.Lock() + self._api_event_queue_lock = threading.Lock() + self.config = load_config() self.event_queue = events.AsyncEventQueue() self._loop = asyncio.new_event_loop() @@ -185,7 +195,8 @@ class App: self.disc_roles: list[str] = list(disc_sec.get("roles", list(DISC_ROLES))) self.active_discussion = disc_sec.get("active", "main") disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) - self.disc_entries: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles) + with self._disc_entries_lock: + self.disc_entries: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles) self.ui_output_dir = self.project.get("output", {}).get("output_dir", "./md_gen") self.ui_files_base_dir = self.project.get("files", {}).get("base_dir", ".") self.ui_shots_base_dir = self.project.get("screenshots", {}).get("base_dir", ".") @@ -216,7 +227,6 @@ class App: self.last_md_path: Path | None = None self.last_file_items: list[Any] = [] self.send_thread: threading.Thread | None = None - self._send_thread_lock = threading.Lock() self.models_thread: threading.Thread | None = None _default_windows = { "Context Hub": True, @@ -241,7 +251,6 @@ class App: self.text_viewer_content = "" self._pending_dialog: ConfirmDialog | None = None self._pending_dialog_open = False - self._pending_dialog_lock = threading.Lock() self._pending_actions: dict[str, ConfirmDialog] = {} self._pending_ask_dialog = False self._ask_dialog_open = False @@ -270,11 +279,8 @@ class App: self._tool_log: list[tuple[str, str, float]] = [] self._comms_log: list[dict[str, Any]] = [] self._pending_comms: list[dict[str, Any]] = [] - self._pending_comms_lock = threading.Lock() self._pending_tool_calls: list[tuple[str, str, float]] = [] - self._pending_tool_calls_lock = threading.Lock() self._pending_history_adds: list[dict[str, Any]] = [] - self._pending_history_adds_lock = threading.Lock() self._trigger_blink = False self._is_blinking = False self._blink_start_time = 0.0 @@ -285,7 +291,6 @@ class App: self._scroll_comms_to_bottom = False self._scroll_tool_calls_to_bottom = False self._pending_gui_tasks: list[dict[str, Any]] = [] - self._pending_gui_tasks_lock = threading.Lock() self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0} self._token_budget_pct = 0.0 self._token_budget_current = 0 @@ -427,6 +432,8 @@ class App: } self._discussion_names_cache: list[str] = [] self._discussion_names_dirty: bool = True + self.hook_server = api_hooks.HookServer(self) + self.hook_server.start() def create_api(self) -> FastAPI: """Creates and configures the FastAPI application for headless mode.""" @@ -669,7 +676,8 @@ class App: self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES))) self.active_discussion = disc_sec.get("active", "main") disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) - self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) + with self._disc_entries_lock: + self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) proj = self.project self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen") self.ui_files_base_dir = proj.get("files", {}).get("base_dir", ".") @@ -712,7 +720,8 @@ class App: if self.active_track: track_history = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) if track_history: - self.disc_entries = _parse_history_entries(track_history, self.disc_roles) + with self._disc_entries_lock: + self.disc_entries = _parse_history_entries(track_history, self.disc_roles) def _cb_load_track(self, track_id: str) -> None: state = project_manager.load_track_state(track_id, self.ui_files_base_dir) @@ -735,10 +744,11 @@ class App: self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in tickets] # Load track-scoped history history = project_manager.load_track_history(track_id, self.ui_files_base_dir) - if history: - self.disc_entries = _parse_history_entries(history, self.disc_roles) - else: - self.disc_entries = [] + with self._disc_entries_lock: + if history: + self.disc_entries = _parse_history_entries(history, self.disc_roles) + else: + self.disc_entries = [] self._recalculate_session_usage() self.ai_status = f"Loaded track: {state.metadata.name}" except Exception as e: @@ -769,12 +779,20 @@ class App: self.ai_status = f"discussion not found: {name}" return self.active_discussion = name + self.active_discussion_idx = -1 + discussions_root = self.project.get("discussions", []) + for i, d in enumerate(discussions_root): + if isinstance(d, dict) and d.get("title") == name: + self.active_discussion_idx = i + break self._track_discussion_active = False disc_sec["active"] = name self._discussion_names_dirty = True disc_data = discussions[name] - self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) + with self._disc_entries_lock: + self.disc_entries = _parse_history_entries(disc_data.get("history", []), self.disc_roles) self.ai_status = f"discussion: {name}" + sys.stderr.write(f'[DEBUG] Switched to {name}. disc_entries len: {len(self.disc_entries)}\n') def _flush_disc_entries_to_project(self) -> None: history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] @@ -827,10 +845,29 @@ class App: # ---------------------------------------------------------------- logic def _on_comms_entry(self, entry: dict) -> None: + # sys.stderr.write(f"[DEBUG] _on_comms_entry: {entry.get('kind')} {entry.get('direction')}\n") session_logger.log_comms(entry) entry["local_ts"] = time.time() + kind = entry.get("kind") + payload = entry.get("payload", {}) + if kind in ("tool_result", "tool_call"): + role = "Tool" if kind == "tool_result" else "Vendor API" + content = "" + if kind == "tool_result": + content = payload.get("output", "") + else: + content = payload.get("script") or payload.get("args") or payload.get("message", "") + if isinstance(content, dict): + content = json.dumps(content, indent=1) + with self._pending_history_adds_lock: + self._pending_history_adds.append({ + "role": role, + "content": f"[{kind.upper().replace('_', ' ')}]\n{content}", + "collapsed": True, + "ts": entry.get("ts", project_manager.now_ts()) + }) # If this is a history_add kind, route it to history queue instead - if entry.get("kind") == "history_add": + if kind == "history_add": payload = entry.get("payload", {}) with self._pending_history_adds_lock: self._pending_history_adds.append({ @@ -1008,6 +1045,32 @@ class App: except Exception as e: print(f"Error executing GUI task: {e}") + def _process_pending_history_adds(self) -> None: + """Synchronizes pending history entries to the active discussion and project state.""" + with self._pending_history_adds_lock: + items = self._pending_history_adds[:] + self._pending_history_adds.clear() + if not items: + return + self._scroll_disc_to_bottom = True + for item in items: + role = item.get("role", "unknown") + if item.get("role") and item["role"] not in self.disc_roles: + self.disc_roles.append(item["role"]) + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + disc_data = discussions.get(self.active_discussion) + if disc_data is not None: + if item.get("disc_title", self.active_discussion) == self.active_discussion: + if self.disc_entries is not disc_data.get("history"): + if "history" not in disc_data: + disc_data["history"] = [] + disc_data["history"].append(project_manager.entry_to_str(item)) + disc_data["last_updated"] = project_manager.now_ts() + with self._disc_entries_lock: + self.disc_entries.append(item) + print(f'[DEBUG] Added to disc_entries. Current len: {len(self.disc_entries)}') + def _handle_approve_script(self) -> None: """Logic for approving a pending script via API hooks.""" print("[DEBUG] _handle_approve_script called") @@ -1142,6 +1205,8 @@ class App: self.ai_status = "session reset" self.ai_response = "" self.ui_ai_input = "" + with self._pending_history_adds_lock: + self._pending_history_adds.clear() def _handle_md_only(self) -> None: """Logic for the 'MD Only' action.""" @@ -1186,6 +1251,17 @@ class App: """Runs the internal asyncio event loop.""" asyncio.set_event_loop(self._loop) self._loop.create_task(self._process_event_queue()) + + # Fallback: process queues even if GUI thread is idling/stuck + async def queue_fallback(): + while True: + try: + self._process_pending_gui_tasks() + self._process_pending_history_adds() + except: pass + await asyncio.sleep(0.1) + + self._loop.create_task(queue_fallback()) self._loop.run_forever() def shutdown(self) -> None: @@ -1205,8 +1281,8 @@ class App: while True: event_name, payload = await self.event_queue.get() if event_name == "user_request": - # Handle the request (simulating what was previously in do_send thread) - self._handle_request_event(payload) + # Handle the request in a separate thread to avoid blocking the loop + self._loop.run_in_executor(None, self._handle_request_event, payload) elif event_name == "response": # Handle AI response event with self._pending_gui_tasks_lock: @@ -1512,9 +1588,9 @@ class App: imgui.text(content) imgui.pop_text_wrap_pos() else: - if imgui.begin_child(f"heavy_text_child_{label}", imgui.ImVec2(0, 80), True): - imgui.input_text_multiline(f"##{label}_input", content, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) - imgui.end_child() + imgui.begin_child(f"heavy_text_child_{label}", imgui.ImVec2(0, 80), True) + imgui.input_text_multiline(f"##{label}_input", content, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) + imgui.end_child() else: if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) @@ -1583,15 +1659,6 @@ class App: for tc in self._pending_tool_calls: self._tool_log.append(tc) self._pending_tool_calls.clear() - # Sync pending history adds - with self._pending_history_adds_lock: - if self._pending_history_adds: - self._scroll_disc_to_bottom = True - for item in self._pending_history_adds: - if item["role"] not in self.disc_roles: - self.disc_roles.append(item["role"]) - self.disc_entries.append(item) - self._pending_history_adds.clear() # ---- Menubar if imgui.begin_main_menu_bar(): if imgui.begin_menu("manual slop"): @@ -1668,9 +1735,9 @@ class App: exp, self.show_windows["Discussion Hub"] = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) if exp: # Top part for the history - if imgui.begin_child("HistoryChild", size=(0, -200)): - self._render_discussion_panel() - imgui.end_child() + imgui.begin_child("HistoryChild", size=(0, -200)) + self._render_discussion_panel() + imgui.end_child() # Bottom part with tabs for message and response if imgui.begin_tab_bar("MessageResponseTabs"): if imgui.begin_tab_item("Message")[0]: @@ -2455,7 +2522,8 @@ class App: if self._track_discussion_active: self._flush_disc_entries_to_project() history_strings = project_manager.load_track_history(self.active_track.id, self.ui_files_base_dir) - self.disc_entries = _parse_history_entries(history_strings, self.disc_roles) + with self._disc_entries_lock: + self.disc_entries = _parse_history_entries(history_strings, self.disc_roles) self.ai_status = f"track discussion: {self.active_track.id}" else: self._flush_disc_entries_to_project() @@ -2521,7 +2589,8 @@ class App: 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) + with self._disc_entries_lock: + 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"): @@ -2831,51 +2900,51 @@ class App: if imgui.button("Clear##tc"): self._tool_log.clear() imgui.separator() - if imgui.begin_child("tc_scroll"): - clipper = imgui.ListClipper() - clipper.begin(len(self._tool_log)) - while clipper.step(): - for i_minus_one in range(clipper.display_start, clipper.display_end): - i = i_minus_one + 1 - script, result, _ = self._tool_log[i_minus_one] - first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)" - imgui.text_colored(C_KEY, f"Call #{i}: {first_line}") - # Script Display - imgui.text_colored(C_LBL, "Script:") - imgui.same_line() - if imgui.button(f"[+]##script_{i}"): - self.show_text_viewer = True - self.text_viewer_title = f"Call Script #{i}" - self.text_viewer_content = script - if self.ui_word_wrap: - if imgui.begin_child(f"tc_script_wrap_{i}", imgui.ImVec2(-1, 72), True): - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(script) - imgui.pop_text_wrap_pos() - imgui.end_child() - else: - if imgui.begin_child(f"tc_script_fixed_width_{i}", imgui.ImVec2(0, 72), True, imgui.WindowFlags_.horizontal_scrollbar): - imgui.input_text_multiline(f"##tc_script_res_{i}", script, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) - imgui.end_child() - # Result Display - imgui.text_colored(C_LBL, "Output:") - imgui.same_line() - if imgui.button(f"[+]##output_{i}"): - self.show_text_viewer = True - self.text_viewer_title = f"Call Output #{i}" - self.text_viewer_content = result - if self.ui_word_wrap: - if imgui.begin_child(f"tc_res_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: - if imgui.begin_child(f"tc_res_fixed_width_{i}", imgui.ImVec2(0, 72), True, imgui.WindowFlags_.horizontal_scrollbar): - imgui.input_text_multiline(f"##tc_res_val_{i}", result, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) - imgui.end_child() - imgui.separator() - imgui.end_child() + imgui.begin_child("scroll_area") + clipper = imgui.ListClipper() + clipper.begin(len(self._tool_log)) + while clipper.step(): + for i_minus_one in range(clipper.display_start, clipper.display_end): + i = i_minus_one + 1 + script, result, _ = self._tool_log[i_minus_one] + first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)" + imgui.text_colored(C_KEY, f"Call #{i}: {first_line}") + # Script Display + imgui.text_colored(C_LBL, "Script:") + imgui.same_line() + if imgui.button(f"[+]##script_{i}"): + self.show_text_viewer = True + self.text_viewer_title = f"Call Script #{i}" + self.text_viewer_content = script + if self.ui_word_wrap: + imgui.begin_child(f"tc_script_wrap_{i}", imgui.ImVec2(-1, 72), True) + imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + imgui.text(script) + imgui.pop_text_wrap_pos() + imgui.end_child() + else: + imgui.begin_child(f"tc_script_fixed_width_{i}", imgui.ImVec2(0, 72), True, imgui.WindowFlags_.horizontal_scrollbar) + imgui.input_text_multiline(f"##tc_script_res_{i}", script, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) + imgui.end_child() + # Result Display + imgui.text_colored(C_LBL, "Output:") + imgui.same_line() + if imgui.button(f"[+]##output_{i}"): + self.show_text_viewer = True + self.text_viewer_title = f"Call Output #{i}" + self.text_viewer_content = result + if self.ui_word_wrap: + imgui.begin_child(f"tc_res_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.begin_child(f"tc_res_fixed_width_{i}", imgui.ImVec2(0, 72), True, imgui.WindowFlags_.horizontal_scrollbar) + imgui.input_text_multiline(f"##tc_res_val_{i}", result, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) + imgui.end_child() + imgui.separator() + imgui.end_child() def _render_comms_history_panel(self) -> None: imgui.text_colored(vec4(200, 220, 160), f"Status: {self.ai_status}") @@ -2895,21 +2964,21 @@ class App: imgui.separator() imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION") imgui.separator() - if imgui.begin_child("comms_scroll"): - clipper = imgui.ListClipper() - clipper.begin(len(self._comms_log)) - while clipper.step(): - for i in range(clipper.display_start, clipper.display_end): - entry = self._comms_log[i] - imgui.text_colored(C_KEY, f"[{entry.get('direction')}] {entry.get('type')}") - imgui.same_line() - if imgui.button(f"[+]##c{i}"): - self.show_text_viewer = True - self.text_viewer_title = f"Comms Entry #{i}" - self.text_viewer_content = json.dumps(entry.get("payload"), indent=2) - imgui.text_unformatted(str(entry.get("payload"))[:200] + "...") - imgui.separator() - imgui.end_child() + imgui.begin_child("scroll_area") + clipper = imgui.ListClipper() + clipper.begin(len(self._comms_log)) + while clipper.step(): + for i in range(clipper.display_start, clipper.display_end): + entry = self._comms_log[i] + imgui.text_colored(C_KEY, f"[{entry.get('direction')}] {entry.get('type')}") + imgui.same_line() + if imgui.button(f"[+]##c{i}"): + self.show_text_viewer = True + self.text_viewer_title = f"Comms Entry #{i}" + self.text_viewer_content = json.dumps(entry.get("payload"), indent=2) + imgui.text_unformatted(str(entry.get("payload"))[:200] + "...") + imgui.separator() + imgui.end_child() def _render_mma_dashboard(self) -> None: # Task 5.3: Dense Summary Line @@ -3172,15 +3241,15 @@ class App: def _render_tier_stream_panel(self, tier_key: str, stream_key: str | None) -> None: if stream_key is not None: content = self.mma_streams.get(stream_key, "") - if imgui.begin_child("##stream_content", imgui.ImVec2(-1, -1)): - imgui.text_wrapped(content) - try: - if len(content) != self._tier_stream_last_len.get(stream_key, -1): - imgui.set_scroll_here_y(1.0) - self._tier_stream_last_len[stream_key] = len(content) - except (TypeError, AttributeError): - pass - imgui.end_child() + imgui.begin_child(f"##stream_content_{tier_key}", imgui.ImVec2(-1, -1)) + imgui.text_wrapped(content) + try: + if len(content) != self._tier_stream_last_len.get(stream_key, -1): + imgui.set_scroll_here_y(1.0) + self._tier_stream_last_len[stream_key] = len(content) + except (TypeError, AttributeError): + pass + imgui.end_child() else: tier3_keys = [k for k in self.mma_streams if "Tier 3" in k] if not tier3_keys: @@ -3189,15 +3258,15 @@ class App: for key in tier3_keys: ticket_id = key.split(": ", 1)[-1] if ": " in key else key imgui.text(ticket_id) - if imgui.begin_child(f"##tier3_{ticket_id}_scroll", imgui.ImVec2(-1, 150), True): - imgui.text_wrapped(self.mma_streams[key]) - try: - if len(self.mma_streams[key]) != self._tier_stream_last_len.get(key, -1): - imgui.set_scroll_here_y(1.0) - self._tier_stream_last_len[key] = len(self.mma_streams[key]) - except (TypeError, AttributeError): - pass - imgui.end_child() + imgui.begin_child(f"##tier3_{ticket_id}_scroll", imgui.ImVec2(-1, 150), True) + imgui.text_wrapped(self.mma_streams[key]) + try: + if len(self.mma_streams[key]) != self._tier_stream_last_len.get(key, -1): + imgui.set_scroll_here_y(1.0) + self._tier_stream_last_len[key] = len(self.mma_streams[key]) + except (TypeError, AttributeError): + pass + imgui.end_child() def _render_ticket_dag_node(self, ticket: Ticket, tickets_by_id: dict[str, Ticket], children_map: dict[str, list[str]], rendered: set[str]) -> None: tid = ticket.get('id', '??') diff --git a/hello.ps1 b/hello.ps1 deleted file mode 100644 index e8c9f08..0000000 --- a/hello.ps1 +++ /dev/null @@ -1 +0,0 @@ -Write-Host "Simulation Test"