From 4b34f83970687a7658fb4ece8bc9c8aa01d732e2 Mon Sep 17 00:00:00 2001 From: razor950 Date: Sun, 7 Jun 2026 01:08:31 -0400 Subject: [PATCH] improved startup first frame boot --- .claude/settings.local.json | 3 +- config.toml | 24 ++--- manualslop_layout.ini | 38 +++---- project_history.toml | 2 +- src/ai_client.py | 11 +- src/app_controller.py | 47 ++++++++- src/bg_shader.py | 1 - src/gui_2.py | 194 +++++++++++++++++++++++++++--------- 8 files changed, 232 insertions(+), 88 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4741f879..fab4dc62 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "mcp__manual-slop__get_file_summary", "mcp__manual-slop__get_tree", "mcp__manual-slop__list_directory", - "mcp__manual-slop__py_get_skeleton" + "mcp__manual-slop__py_get_skeleton", + "Bash(uv run *)" ] }, "enableAllProjectMcpServers": true, diff --git a/config.toml b/config.toml index 359c8dee..fcd788b5 100644 --- a/config.toml +++ b/config.toml @@ -12,11 +12,9 @@ use_default_base_prompt = true [projects] paths = [ - "C:/projects/gencpp/.ai/gencpp_sloppy.toml", - "C:/projects/manual_slop/manual_slop.toml", - "C:/projects/Pikuma/ps1-ai/pikuma_ps1.toml", + "project.toml", ] -active = "C:/projects/Pikuma/ps1-ai/pikuma_ps1.toml" +active = "project.toml" [gui] separate_message_panel = true @@ -62,23 +60,23 @@ Diagnostics = false "Undo/Redo History" = false [theme] -palette = "solarized_dark" -font_path = "C:/projects/manual_slop/assets/fonts/MapleMono-Regular.ttf" +palette = "Monokai" +font_path = "fonts/MapleMono-Regular.ttf" font_size = 20.0 scale = 1.0199999809265137 transparency = 1.0 child_transparency = 1.0 -[theme.tone_mapping.solarized_light] -brightness = 0.6899999976158142 -contrast = 0.8600000143051147 -gamma = 0.7699999809265137 - [theme.tone_mapping.moss] brightness = 1.059999942779541 contrast = 0.5799999833106995 gamma = 1.059999942779541 +[theme.tone_mapping.solarized_light] +brightness = 0.6899999976158142 +contrast = 0.8600000143051147 +gamma = 0.7699999809265137 + [theme.tone_mapping.Binks] brightness = 0.5600000023841858 contrast = 0.7900000214576721 @@ -97,8 +95,8 @@ api_key = "test-secret-key" [paths] conductor_dir = "C:\\projects\\gencpp\\.ai\\conductor" -logs_dir = "C:\\projects\\manual_slop\\logs" -scripts_dir = "C:\\projects\\manual_slop\\scripts" +logs_dir = "C:\\projects\\sloppy\\logs" +scripts_dir = "C:\\projects\\sloppy\\scripts" [rag] enabled = false diff --git a/manualslop_layout.ini b/manualslop_layout.ini index e8c2f11e..886fb9d2 100644 --- a/manualslop_layout.ini +++ b/manualslop_layout.ini @@ -44,20 +44,20 @@ Collapsed=0 DockId=0x00000010,0 [Window][Message] -Pos=1448,29 -Size=1465,1840 +Pos=561,29 +Size=1138,1195 Collapsed=0 DockId=0x00000006,1 [Window][Response] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,5 [Window][Tool Calls] -Pos=1448,29 -Size=1465,1840 +Pos=561,29 +Size=1138,1195 Collapsed=0 DockId=0x00000006,3 @@ -77,7 +77,7 @@ DockId=0xAFC85805,2 [Window][Theme] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,1 @@ -105,26 +105,26 @@ Collapsed=0 DockId=0x0000000D,0 [Window][Discussion Hub] -Pos=1448,29 -Size=1465,1840 +Pos=561,29 +Size=1138,1195 Collapsed=0 DockId=0x00000006,0 [Window][Operations Hub] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,4 [Window][Files & Media] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,2 [Window][AI Settings] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,3 @@ -140,8 +140,8 @@ Collapsed=0 DockId=0x00000006,2 [Window][Log Management] -Pos=1448,29 -Size=1465,1840 +Pos=561,29 +Size=1138,1195 Collapsed=0 DockId=0x00000006,2 @@ -410,7 +410,7 @@ DockId=0x00000006,1 [Window][Project Settings] Pos=0,29 -Size=1446,1840 +Size=559,1195 Collapsed=0 DockId=0x00000010,0 @@ -510,7 +510,7 @@ Pos=60,60 Size=900,700 Collapsed=0 -[Window][###Text_Viewer] +[Window][Text_Viewer] Pos=58,169 Size=1801,1532 Collapsed=0 @@ -520,7 +520,7 @@ Pos=156,171 Size=2176,1441 Collapsed=0 -[Window][###Text_Viewer_Unified] +[Window][Text_Viewer_Unified] Pos=182,742 Size=1163,908 Collapsed=0 @@ -829,13 +829,13 @@ Column 4 Weight=1.0000 DockNode ID=0x00000008 Pos=3125,170 Size=593,1157 Split=Y DockNode ID=0x00000009 Parent=0x00000008 SizeRef=1029,147 Selected=0x0469CA7A DockNode ID=0x0000000A Parent=0x00000008 SizeRef=1029,145 Selected=0xDF822E02 -DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,29 Size=2913,1840 Split=X +DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=0,29 Size=1699,1195 Split=X DockNode ID=0x00000003 Parent=0xAFC85805 SizeRef=2357,1183 Split=X DockNode ID=0x0000000B Parent=0x00000003 SizeRef=404,1186 Split=X Selected=0xF4139CA2 - DockNode ID=0x00000005 Parent=0x0000000B SizeRef=1446,1681 Split=Y Selected=0x3F1379AF + DockNode ID=0x00000005 Parent=0x0000000B SizeRef=573,1681 Split=Y Selected=0x3F1379AF DockNode ID=0x00000010 Parent=0x00000005 SizeRef=983,1140 CentralNode=1 Selected=0x3F1379AF DockNode ID=0x00000011 Parent=0x00000005 SizeRef=983,184 Selected=0x432BAE4E - DockNode ID=0x00000006 Parent=0x0000000B SizeRef=1465,1681 Selected=0x2C0206CE + DockNode ID=0x00000006 Parent=0x0000000B SizeRef=1138,1681 Selected=0x2C0206CE DockNode ID=0x0000000D Parent=0x00000003 SizeRef=435,1186 Selected=0x363E93D6 DockNode ID=0x00000004 Parent=0xAFC85805 SizeRef=488,1183 Selected=0x3AEC3498 diff --git a/project_history.toml b/project_history.toml index d7663da6..a822d5d7 100644 --- a/project_history.toml +++ b/project_history.toml @@ -9,5 +9,5 @@ active = "main" [discussions.main] git_commit = "" -last_updated = "2026-06-05T18:41:59" +last_updated = "2026-06-06T13:21:40" history = [] diff --git a/src/ai_client.py b/src/ai_client.py index e2e08039..09d38984 100644 --- a/src/ai_client.py +++ b/src/ai_client.py @@ -421,13 +421,22 @@ def _classify_minimax_error(exc: Exception) -> ProviderError: if "400" in body_l or "bad request" in body_l: return ProviderError("unknown", "minimax", Exception(f"MiniMax Bad Request: {body}")) return ProviderError("unknown", "minimax", Exception(body)) -def set_provider(provider: str, model: str) -> None: +def set_provider(provider: str, model: str, validate: bool = True) -> None: """ Updates the active LLM provider and model name. + + When validate is True (default), the model is checked against the provider's + LIVE model list, which for gemini_cli/minimax means a blocking subprocess / + network call (and importing the provider SDK). Pass validate=False during + startup so the GUI's first frame is not blocked — AppController._fetch_models + corrects the model against the live list shortly after, off the main thread. [C: src/app_controller.py:AppController._handle_reset_session, src/app_controller.py:AppController._init_ai_and_hooks, src/app_controller.py:AppController.current_model, src/app_controller.py:AppController.current_provider, src/app_controller.py:AppController.do_fetch, src/multi_agent_conductor.py:run_worker_lifecycle, src/orchestrator_pm.py:generate_tracks, tests/conftest.py:reset_ai_client, tests/test_ai_cache_tracking.py:test_gemini_cache_tracking, tests/test_ai_client_cli.py:test_ai_client_send_gemini_cli, tests/test_api_events.py:test_send_emits_events_proper, tests/test_api_events.py:test_send_emits_tool_events, tests/test_deepseek_provider.py:test_deepseek_completion_logic, tests/test_deepseek_provider.py:test_deepseek_model_selection, tests/test_deepseek_provider.py:test_deepseek_payload_verification, tests/test_deepseek_provider.py:test_deepseek_reasoner_payload_verification, tests/test_deepseek_provider.py:test_deepseek_reasoning_logic, tests/test_deepseek_provider.py:test_deepseek_streaming, tests/test_deepseek_provider.py:test_deepseek_tool_calling, tests/test_gemini_cli_edge_cases.py:test_gemini_cli_loop_termination, tests/test_gemini_cli_integration.py:test_gemini_cli_full_integration, tests/test_gemini_cli_integration.py:test_gemini_cli_rejection_and_history, tests/test_gemini_cli_parity_regression.py:test_send_invokes_adapter_send, tests/test_gui2_mcp.py:test_mcp_tool_call_is_dispatched, tests/test_minimax_provider.py:test_minimax_default_model, tests/test_minimax_provider.py:test_minimax_model_selection, tests/test_mma_agent_focus_phase1.py:test_append_comms_has_source_tier_key, tests/test_rag_integration.py:test_rag_integration, tests/test_tier4_interceptor.py:test_ai_client_passes_qa_callback, tests/test_tier4_interceptor.py:test_gemini_provider_passes_qa_callback_to_run_script, tests/test_token_usage.py:test_token_usage_tracking] """ global _provider, _model _provider = provider + if not validate: + _model = model + return if provider == "gemini_cli": valid_models = _list_gemini_cli_models() if model != "mock" and (model not in valid_models or model.startswith("deepseek")): diff --git a/src/app_controller.py b/src/app_controller.py index 34abff80..a84ecdbe 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -801,7 +801,7 @@ class AppController: Owns the application state and manages background services. """ - def __init__(self, log_to_stderr: bool = True): + def __init__(self, defer_warmup: bool = False, log_to_stderr: Optional[bool] = None): """ [C: src/mcp_client.py:_DDGParser.__init__, src/mcp_client.py:_TextExtractor.__init__] """ @@ -823,10 +823,22 @@ class AppController: # --- Shared background pool + proactive warmup (startup_speedup_20260606) --- self._io_pool = make_io_pool() + # Warmup progress is a diagnostic; keep stderr quiet unless explicitly asked. + # Explicit log_to_stderr arg wins; otherwise default to the SLOP_WARMUP_DEBUG env flag. + if log_to_stderr is None: + log_to_stderr = bool(os.environ.get("SLOP_WARMUP_DEBUG")) self._warmup = WarmupManager(self._io_pool, log_to_stderr=log_to_stderr) # Hook warmup completion to stamp warmup_done_ts for startup_timeline(). self._warmup.on_complete(self._on_warmup_complete_for_timeline) - self._warmup.submit(self._compute_warmup_list()) + self._warmup_started: bool = False + self._defer_warmup: bool = defer_warmup + self._pending_fetch_provider: Optional[str] = None + # The desktop GUI defers warmup until the first frame is painted (see + # App._gui_func) so the ~2s of heavy SDK C-extension imports don't hold the + # GIL while the window and font atlas are being created — that contention is + # what made the window slow to appear. Headless/web/tests warm immediately. + if not defer_warmup: + self.start_warmup() # --- Internal State --- self._ai_status: str = "idle" @@ -2132,6 +2144,13 @@ class AppController: """ [C: src/gui_2.py:App.run] """ + # In the desktop GUI, model listing imports the provider SDKs (the same + # ~2s C-extension load warmup pays for). Defer it until the first frame is + # painted so it doesn't contend for the GIL during window creation; the + # deferred fetch is fired from start_warmup(). + if self._defer_warmup and not self._warmup_started: + self._pending_fetch_provider = provider + return self.ai_status = "fetching models..." def do_fetch() -> None: @@ -2213,9 +2232,26 @@ class AppController: """ return self._warmup.is_done() + def start_warmup(self) -> None: + """ + Submit the heavy-module warmup jobs to the io_pool (idempotent). + + Separated from __init__ so the desktop GUI can call it AFTER the first + frame is painted, keeping the ~2s of SDK C-extension imports off the GIL + while the window is being created. Safe to call multiple times. + """ + if self._warmup_started: + return + self._warmup_started = True + self._warmup.submit(self._compute_warmup_list()) + # Run any model fetch that was deferred while the window was being created. + if self._pending_fetch_provider is not None: + provider, self._pending_fetch_provider = self._pending_fetch_provider, None + self._fetch_models(provider) + def wait_for_warmup(self, timeout: Optional[float] = None) -> bool: """ - + Block until warmup completes. Returns True on done, False on timeout. [SDM: src/app_controller.py:wait_for_warmup] """ @@ -2268,7 +2304,10 @@ class AppController: def _init_ai_and_hooks(self, app: Any = None) -> None: from src import api_hooks - ai_client.set_provider(self._current_provider, self._current_model) + # validate=False: skip the live model-list lookup (network/subprocess + + # provider-SDK import) on the main thread during startup. _fetch_models + # corrects the model against the live list after the first frame, off-thread. + ai_client.set_provider(self._current_provider, self._current_model, validate=False) if self._current_provider == "gemini_cli": if not ai_client._gemini_cli_adapter: ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=self.ui_gemini_cli_path) diff --git a/src/bg_shader.py b/src/bg_shader.py index d2d88e06..62515af2 100644 --- a/src/bg_shader.py +++ b/src/bg_shader.py @@ -1,7 +1,6 @@ # src/bg_shader.py import time import math -import numpy as np from typing import Optional from imgui_bundle import imgui, nanovg as nvg, hello_imgui diff --git a/src/gui_2.py b/src/gui_2.py index 593330c4..1adf6454 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -161,6 +161,75 @@ def truncate_entries(entries: list[dict[str, Any]], max_pairs: int) -> list[dict if count == target: return entries[i:] return entries +def _detect_refresh_rate_win32() -> float: + """Return the primary display's current refresh rate in Hz, or 0.0 on failure. + + Uses user32.EnumDisplaySettingsW (ENUM_CURRENT_SETTINGS) which reads the value + directly from the display driver in microseconds. The previous implementation + shelled out to PowerShell + WMI (Get-CimInstance Win32_VideoController), which + cost ~350ms on every startup and blocked the first frame. + """ + try: + import ctypes + from ctypes import wintypes + class _DEVMODE(ctypes.Structure): + _fields_ = [ + ("dmDeviceName", wintypes.WCHAR * 32), ("dmSpecVersion", wintypes.WORD), + ("dmDriverVersion", wintypes.WORD), ("dmSize", wintypes.WORD), + ("dmDriverExtra", wintypes.WORD), ("dmFields", wintypes.DWORD), + ("dmStuff", ctypes.c_byte * 16), ("dmColor", wintypes.SHORT), + ("dmDuplex", wintypes.SHORT), ("dmYResolution", wintypes.SHORT), + ("dmTTOption", wintypes.SHORT), ("dmCollate", wintypes.SHORT), + ("dmFormName", wintypes.WCHAR * 32), ("dmLogPixels", wintypes.WORD), + ("dmBitsPerPel", wintypes.DWORD), ("dmPelsWidth", wintypes.DWORD), + ("dmPelsHeight", wintypes.DWORD), ("dmDisplayFlags", wintypes.DWORD), + ("dmDisplayFrequency", wintypes.DWORD), ("dmICMMethod", wintypes.DWORD), + ("dmICMIntent", wintypes.DWORD), ("dmMediaType", wintypes.DWORD), + ("dmDitherType", wintypes.DWORD), ("dmReserved1", wintypes.DWORD), + ("dmReserved2", wintypes.DWORD), ("dmPanningWidth", wintypes.DWORD), + ("dmPanningHeight", wintypes.DWORD), + ] + dm = _DEVMODE() + dm.dmSize = ctypes.sizeof(_DEVMODE) + if ctypes.windll.user32.EnumDisplaySettingsW(None, -1, ctypes.byref(dm)): + # dmDisplayFrequency is 0 or 1 for "default/hardware" on some drivers. + if dm.dmDisplayFrequency > 1: + return float(dm.dmDisplayFrequency) + except Exception: + pass + return 0.0 + +def _resolve_font_path(font_path: str, assets_dir: Path) -> str: + """Normalize a configured font path to something hello_imgui can load. + + hello_imgui resolves relative paths against the assets folder. A config may + carry a stale ABSOLUTE path from a different project checkout (e.g. + C:/projects/manual_slop/assets/fonts/MapleMono-Regular.ttf after the repo + moved to C:/projects/sloppy). In that case the absolute file does not exist + and the load fails. This recovers by: + + 1. If the absolute path lives under the current assets folder -> relativize. + 2. If the absolute path exists on disk as-is -> keep it. + 3. Otherwise recover the basename under assets/fonts or assets. + 4. Final fallback: the bundled default Inter font. + """ + p = Path(font_path) + if not p.is_absolute(): + return font_path # already relative; hello_imgui searches the assets folder + try: + if p.is_relative_to(assets_dir): + return str(p.relative_to(assets_dir)).replace("\\", "/") + except (ValueError, AttributeError): + pass + if p.exists(): + return font_path # absolute path to a real file elsewhere — load directly + # Stale absolute path: recover by basename relative to the assets folder. + name = p.name + for rel in (f"fonts/{name}", name): + if (assets_dir / rel).exists(): + return rel + return "fonts/Inter-Regular.ttf" + class App: """The main ImGui interface orchestrator for Manual Slop.""" @@ -172,7 +241,7 @@ class App: # --- Core Dependencies & State --- from src.startup_profiler import startup_profiler with startup_profiler.phase("app_init_AppController"): - self.controller = app_controller.AppController() + self.controller = app_controller.AppController(defer_warmup=True) self.controller._app = self with startup_profiler.phase("app_init_history_perfmon"): from src import history, performance_monitor @@ -187,7 +256,8 @@ class App: # --- Command Palette --- self.show_command_palette: bool = False # --- Initialization --- - self.controller.init_state() + with startup_profiler.phase("app_init_state"): + self.controller.init_state() from src.hot_reloader import HotReloader, HotModule if 'src.gui_2' not in HotReloader.HOT_MODULES: HotReloader.register(HotModule( @@ -196,11 +266,13 @@ class App: state_keys=['active_discussion', 'show_windows', 'ui_file_paths', 'ui_screenshot_paths', 'disc_entries', 'disc_roles'], delegation_targets=['_render_main_interface', '_render_discussion_hub', '_render_files_and_media', '_render_ai_settings_hub', '_render_operations_hub', '_render_mma_dashboard'] )) - self.workspace_manager = workspace_manager.WorkspaceManager(project_root=self.controller.active_project_root) - self.disc_entries = self.controller.disc_entries - self.disc_roles = self.controller.disc_roles - self.workspace_profiles = self.workspace_manager.load_all_profiles() - self.controller.start_services(self) + with startup_profiler.phase("app_init_workspace"): + self.workspace_manager = workspace_manager.WorkspaceManager(project_root=self.controller.active_project_root) + self.disc_entries = self.controller.disc_entries + self.disc_roles = self.controller.disc_roles + self.workspace_profiles = self.workspace_manager.load_all_profiles() + with startup_profiler.phase("app_init_start_services"): + self.controller.start_services(self) # --- Controller Callbacks & Actions --- self.controller._predefined_callbacks['save_context_preset'] = self.save_context_preset self.controller._predefined_callbacks['load_context_preset'] = self.load_context_preset @@ -457,16 +529,13 @@ class App: user_scale = theme.get_current_scale() self.runner_params.dpi_aware_params.dpi_window_size_factor = user_scale - # Detect Monitor Refresh Rate for capping (Win32 only) + # Detect Monitor Refresh Rate for capping (Win32 only). + # Uses the native EnumDisplaySettings call (~0.3ms) instead of spawning a + # PowerShell/WMI subprocess (~350ms) so the first frame is not blocked. fps_cap = 60.0 if sys.platform == "win32": - try: - # Use PowerShell to get max refresh rate across all controllers - cmd = "powershell -NoProfile -Command \"Get-CimInstance -ClassName Win32_VideoController | Select-Object -ExpandProperty CurrentRefreshRate\"" - out = subprocess.check_output(cmd, shell=True).decode().splitlines() - rates = [float(r.strip()) for r in out if r.strip().isdigit()] - if rates: fps_cap = max(rates) - except Exception: pass + rate = _detect_refresh_rate_win32() + if rate: fps_cap = rate # Enable idling with monitor refresh rate to effectively cap FPS self.runner_params.fps_idling.enable_idling = True @@ -476,11 +545,17 @@ class App: self.runner_params.imgui_window_params.show_menu_view_themes = True self.runner_params.ini_folder_type = hello_imgui.IniFolderType.current_folder self.runner_params.ini_filename = "manualslop_layout.ini" + def _profiled_setup_style() -> None: + with startup_profiler.phase("setup_imgui_style"): + theme.apply_current() + def _profiled_post_init() -> None: + with startup_profiler.phase("post_init"): + self._post_init() self.runner_params.callbacks.show_gui = self._gui_func self.runner_params.callbacks.show_menus = self._show_menus self.runner_params.callbacks.load_additional_fonts = self._load_fonts - self.runner_params.callbacks.setup_imgui_style = theme.apply_current - self.runner_params.callbacks.post_init = self._post_init + self.runner_params.callbacks.setup_imgui_style = _profiled_setup_style + self.runner_params.callbacks.post_init = _profiled_post_init self._fetch_models(self.current_provider) md_options = markdown_helper.get_renderer().options immapp.run(self.runner_params, add_ons_params=immapp.AddOnsParams(with_markdown_options=md_options)) @@ -489,42 +564,39 @@ class App: session_logger.close_session() def _load_fonts(self) -> None: - # Set hello_imgui assets folder to the actual absolute path - assets_dir = Path(__file__).parent.parent / "assets" - if assets_dir.exists(): - hello_imgui.set_assets_folder(str(assets_dir.absolute())) + from src.startup_profiler import startup_profiler + with startup_profiler.phase("load_fonts"): + # Set hello_imgui assets folder to the actual absolute path + assets_dir = Path(__file__).parent.parent / "assets" + if assets_dir.exists(): + hello_imgui.set_assets_folder(str(assets_dir.absolute())) - # Improved font rendering with oversampling - config = imgui.ImFontConfig() - config.oversample_h = 3 - config.oversample_v = 3 + # Improved font rendering with oversampling + config = imgui.ImFontConfig() + config.oversample_h = 3 + config.oversample_v = 3 - font_path, font_size = theme.get_font_loading_params() - - if font_path: - p = Path(font_path) - if p.is_absolute(): + font_path, font_size = theme.get_font_loading_params() + + if font_path: + font_path = _resolve_font_path(font_path, assets_dir) + # Just try loading it directly; hello_imgui will look in the assets folder try: - if p.is_relative_to(assets_dir): - font_path = str(p.relative_to(assets_dir)).replace("\\", "/") - except (ValueError, AttributeError): - pass # Fallback to original font_path if relative_to fails or on old Python - - # Just try loading it directly; hello_imgui will look in the assets folder - try: - self.main_font = hello_imgui.load_font_ttf_with_font_awesome_icons(font_path, font_size, config) - except Exception as e: - print(f"Failed to load main font {font_path}: {e}") + with startup_profiler.phase("load_fonts.main_with_fontawesome"): + self.main_font = hello_imgui.load_font_ttf_with_font_awesome_icons(font_path, font_size, config) + except Exception as e: + print(f"Failed to load main font {font_path}: {e}") + self.main_font = None + else: self.main_font = None - else: - self.main_font = None - try: - params = hello_imgui.FontLoadingParams(font_config=config) - self.mono_font = hello_imgui.load_font("fonts/MapleMono-Regular.ttf", font_size, params) - except Exception as e: - print(f"Failed to load mono font: {e}") - self.mono_font = None + try: + with startup_profiler.phase("load_fonts.mono"): + params = hello_imgui.FontLoadingParams(font_config=config) + self.mono_font = hello_imgui.load_font("fonts/MapleMono-Regular.ttf", font_size, params) + except Exception as e: + print(f"Failed to load mono font: {e}") + self.mono_font = None def _handle_approve_mma_step(self, user_data=None) -> None: """UI-level wrapper for approving a pending MMA step.""" @@ -817,6 +889,32 @@ class App: # ---------------------------------------------------------------- gui def _gui_func(self) -> None: + # One-shot: log when immapp first hands control to our render callback. The + # span init -> here is window/GL/context creation + the font/style/post_init + # callbacks (all opaque C++); the span here -> mark_first_frame_rendered is + # the cost of the first full-UI render. Splitting them attributes the gap. + if not getattr(self, "_gui_func_entered", False): + self._gui_func_entered = True + try: + init_ts = getattr(self.controller, "_init_start_ts", None) + if init_ts is not None: + sys.stderr.write(f"[startup] first _gui_func entry at {(time.time() - init_ts) * 1000:.1f}ms after init (window/GL + font/style/post_init callbacks done)\n") + sys.stderr.flush() + except Exception: pass + + # One-shot: kick off the controller's heavy-module warmup on the shared + # io_pool once the FIRST frame has actually been painted. Waiting one frame + # keeps the ~2s of SDK C-extension imports from holding the GIL during + # window creation and font-atlas building, so the window appears at full + # speed; the SDKs are then warmed by the time the user sends their first + # message. start_warmup() is idempotent. + if not getattr(self, "_preload_started", False): + if getattr(self, "_first_frame_painted", False): + self.controller.start_warmup() + self._preload_started = True + else: + self._first_frame_painted = True + io = imgui.get_io() if io.key_ctrl and io.key_alt and imgui.is_key_down(imgui.Key.r): self._trigger_hot_reload()