diff --git a/conductor/tracks.md b/conductor/tracks.md index 87aef3b..b16e18b 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -36,7 +36,7 @@ This file tracks all major tracks for the project. Each track has its own detail *Link: [./tracks/log_session_overhaul_20260308/](./tracks/log_session_overhaul_20260308/)* *Goal: Centralize log management, improve session restoration reliability with full-UI replay mode, and optimize log size via external script/output referencing. Implement transient diagnostic logging for system warnings.* -2. [ ] **Track: UI Theme Overhaul & Style System** +2. [x] **Track: UI Theme Overhaul & Style System** *Link: [./tracks/ui_theme_overhaul_20260308/](./tracks/ui_theme_overhaul_20260308/)* *Goal: Modernize UI with Inter/Maple Mono fonts, a professional subtle rounded theme, custom shaders (corners, blur, AA), multi-viewport support, and layout presets.* @@ -44,7 +44,7 @@ This file tracks all major tracks for the project. Each track has its own detail *Link: [./tracks/selectable_ui_text_20260308/](./tracks/selectable_ui_text_20260308/)* *Goal: Address UI inconveniences by making critical text across the GUI selectable and copyable. Covers discussion history, comms logs, tool outputs, and key metrics.* -4. [ ] **Track: Markdown Support & Syntax Highlighting** +4. [x] **Track: Markdown Support & Syntax Highlighting** *Link: [./tracks/markdown_highlighting_20260308/](./tracks/markdown_highlighting_20260308/)* *Goal: Add rich text rendering with GFM support and syntax highlighting for PowerShell, Python, and JSON/TOML in read-only message and log views.* diff --git a/conductor/tracks/markdown_highlighting_20260308/plan.md b/conductor/tracks/markdown_highlighting_20260308/plan.md index ab4420c..dcef724 100644 --- a/conductor/tracks/markdown_highlighting_20260308/plan.md +++ b/conductor/tracks/markdown_highlighting_20260308/plan.md @@ -9,28 +9,28 @@ - [x] Task: Conductor - User Manual Verification 'Phase 1: Markdown Integration' (Protocol in workflow.md) ## Phase 2: Syntax Highlighting Implementation -- [ ] Task: Implement syntax highlighting for PowerShell, Python, and JSON/TOML. - - [ ] Research `imgui-bundle`'s recommended approach for syntax highlighting (e.g., using `ImGuiColorTextEdit` or specialized Markdown callbacks). - - [ ] Define language-specific color palettes that match the "Professional" theme. -- [ ] Task: Implement the language resolution logic. - - [ ] Create a utility to extract language tags from code blocks and resolve file extensions. - - [ ] Implement cheap heuristic for common code patterns (e.g., matching `def `, `if $`, `{ "`). -- [ ] Task: Conductor - User Manual Verification 'Phase 2: Syntax Highlighting' (Protocol in workflow.md) +- [x] Task: Implement syntax highlighting for PowerShell, Python, and JSON/TOML. + - [x] Research `imgui-bundle`'s recommended approach for syntax highlighting (e.g., using `ImGuiColorTextEdit` or specialized Markdown callbacks). + - [x] Define language-specific color palettes that match the "Professional" theme. +- [x] Task: Implement the language resolution logic. + - [x] Create a utility to extract language tags from code blocks and resolve file extensions. + - [x] Implement cheap heuristic for common code patterns (e.g., matching `def `, `if $`, `{ "`). +- [x] Task: Conductor - User Manual Verification 'Phase 2: Syntax Highlighting' (Protocol in workflow.md) ## Phase 3: GUI Integration (Read-Only Views) -- [ ] Task: Integrate Markdown rendering into the Discussion History. - - [ ] Replace `imgui.text_wrapped` in `_render_discussion_panel` with the `MarkdownRenderer`. - - [ ] Ensure that code blocks within AI messages are correctly highlighted. -- [ ] Task: Integrate syntax highlighting into the Comms Log. - - [ ] Update `_render_comms_history_panel` to render JSON/TOML payloads with highlighting. -- [ ] Task: Integrate syntax highlighting into the Operations/Tooling panels. - - [ ] Ensure PowerShell scripts and tool results are rendered with highlighting. -- [ ] Task: Conductor - User Manual Verification 'Phase 3: GUI Integration' (Protocol in workflow.md) +- [x] Task: Integrate Markdown rendering into the Discussion History. + - [x] Replace `imgui.text_wrapped` in `_render_discussion_panel` with the `MarkdownRenderer`. + - [x] Ensure that code blocks within AI messages are correctly highlighted. +- [x] Task: Integrate syntax highlighting into the Comms Log. + - [x] Update `_render_comms_history_panel` to render JSON/TOML payloads with highlighting. +- [x] Task: Integrate syntax highlighting into the Operations/Tooling panels. + - [x] Ensure PowerShell scripts and tool results are rendered with highlighting. +- [x] Task: Conductor - User Manual Verification 'Phase 3: GUI Integration' (Protocol in workflow.md) ## Phase 4: Refinement & Final Polish -- [ ] Task: Refine performance for large logs. - - [ ] Implement incremental rendering or caching for rendered Markdown blocks to maintain high FPS. -- [ ] Task: Implement clickable links. - - [ ] Handle link callbacks to open external URLs in the browser or local files in the configured text editor. -- [ ] Task: Conduct a final visual audit across all read-only views. -- [ ] Task: Conductor - User Manual Verification 'Phase 4: Final Polish' (Protocol in workflow.md) +- [x] Task: Refine performance for large logs. + - [x] Implement incremental rendering or caching for rendered Markdown blocks to maintain high FPS. (Hybrid renderer with TextEditor caching implemented). +- [x] Task: Implement clickable links. + - [x] Handle link callbacks to open external URLs in the browser or local files in the configured text editor. +- [x] Task: Conduct a final visual audit across all read-only views. +- [x] Task: Conductor - User Manual Verification 'Phase 4: Final Polish' (Protocol in workflow.md) diff --git a/conductor/tracks/ui_theme_overhaul_20260308/plan.md b/conductor/tracks/ui_theme_overhaul_20260308/plan.md index d80bbc3..196ca76 100644 --- a/conductor/tracks/ui_theme_overhaul_20260308/plan.md +++ b/conductor/tracks/ui_theme_overhaul_20260308/plan.md @@ -26,7 +26,7 @@ - [x] Task: Perform a performance audit to ensure shaders do not degrade FPS. 0b49b3a - [x] Task: Conductor - User Manual Verification 'Phase 3: Advanced Visual Effects (Shaders)' (Protocol in workflow.md) -## Phase 4: Layout Management & Multi-Viewport [checkpoint: 429bb92] +## Phase 4: Layout Management & Multi-Viewport [checkpoint: 5efd775] - [x] Task: Implement Multi-Viewport Support. 429bb92 - [x] Add the "Multi-Viewport" toggle checkbox to the main menu bar. - [x] Ensure the application correctly handles panel detachment and re-attachment. @@ -35,9 +35,9 @@ - [x] Ensure presets capture window geometry and the Multi-Viewport state. - [x] Persist layout presets to the project configuration (`manual_slop.toml` or a dedicated file). - [x] Task: Verify layout restoration accuracy across multiple presets. 429bb92 -- [~] Task: Conductor - User Manual Verification 'Phase 4: Layout Management & Multi-Viewport' (Protocol in workflow.md) +- [x] Task: Conductor - User Manual Verification 'Phase 4: Layout Management & Multi-Viewport' (Protocol in workflow.md) -## Phase 5: Final Polish & Verification -- [ ] Task: Conduct a final UI audit for "professionalism" and consistency. -- [ ] Task: Run the full simulation suite to ensure no regressions in tool interaction or workflow. -- [ ] Task: Conductor - User Manual Verification 'Phase 5: Final Polish & Verification' (Protocol in workflow.md) +## Phase 5: Final Polish & Verification [checkpoint: 5efd775] +- [x] Task: Conduct a final UI audit for "professionalism" and consistency. +- [x] Task: Run the full simulation suite to ensure no regressions in tool interaction or workflow. +- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Polish & Verification' (Protocol in workflow.md) diff --git a/scripts/patch_selectable_metrics.py b/scripts/patch_selectable_metrics.py new file mode 100644 index 0000000..1cda93a --- /dev/null +++ b/scripts/patch_selectable_metrics.py @@ -0,0 +1,47 @@ + +import sys +import re + +def patch_gui(file_path): + with open(file_path, 'r', encoding='utf-8', newline='') as f: + content = f.read() + + # 1. Patch _render_provider_panel Session ID + content = content.replace( + ' imgui.text(f"Session ID: {sid}")', + ' imgui.text("Session ID:"); imgui.same_line(); self._render_selectable_label("gemini_cli_sid", sid, width=200)' + ) + + # 2. Patch _render_token_budget_panel Session Telemetry + content = content.replace( + ' imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage[\'input_tokens\']:,} Out: {usage[\'output_tokens\']:,})")', + ' self._render_selectable_label("session_telemetry_tokens", f"Tokens: {total:,} (In: {usage[\'input_tokens\']:,} Out: {usage[\'output_tokens\']:,})", width=-1, color=C_RES)' + ) + + # 3. Patch _render_token_budget_panel MMA Tier Costs table + # This is trickier, let's find the loop + tier_table_pattern = re.compile( + r'(for tier, stats in self\.mma_tier_usage\.items\(\):\s+.*?imgui\.table_set_column_index\(0\); )imgui\.text\(tier\)(\s+imgui\.table_set_column_index\(1\); )imgui\.text\(model\.split\(\'-\'\)\[0\]\)(\s+imgui\.table_set_column_index\(2\); )imgui\.text\(f"\{tokens:,\}"\)(\s+imgui\.table_set_column_index\(3\); )imgui\.text_colored\(imgui\.ImVec4\(0\.2, 0\.9, 0\.2, 1\), f"\$\{cost:\.4f\}"\)', + re.DOTALL + ) + + def tier_replacement(match): + return (match.group(1) + 'self._render_selectable_label(f"tier_{tier}", tier, width=-1)' + + match.group(2) + 'self._render_selectable_label(f"model_{tier}", model.split("-")[0], width=-1)' + + match.group(3) + 'self._render_selectable_label(f"tokens_{tier}", f"{tokens:,}", width=-1)' + + match.group(4) + 'self._render_selectable_label(f"cost_{tier}", f"${cost:.4f}", width=-1, color=imgui.ImVec4(0.2, 0.9, 0.2, 1))') + + content = tier_table_pattern.sub(tier_replacement, content) + + # 4. Patch _render_token_budget_panel Session Total + content = content.replace( + ' imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"Session Total: ${tier_total:.4f}")', + ' self._render_selectable_label("session_total_cost", f"Session Total: ${tier_total:.4f}", width=-1, color=imgui.ImVec4(0, 1, 0, 1))' + ) + + with open(file_path, 'w', encoding='utf-8', newline='') as f: + f.write(content) + print("Successfully patched src/gui_2.py for selectable metrics") + +if __name__ == "__main__": + patch_gui("src/gui_2.py") diff --git a/scripts/tasks/download_fonts.py b/scripts/tasks/download_fonts.py index e8cf167..18de8ca 100644 --- a/scripts/tasks/download_fonts.py +++ b/scripts/tasks/download_fonts.py @@ -13,9 +13,27 @@ try: with urllib.request.urlopen(req) as response: with zipfile.ZipFile(io.BytesIO(response.read())) as z: for info in z.infolist(): - if info.filename.endswith("Inter-Regular.ttf") or info.filename.endswith("Inter-Bold.ttf"): - info.filename = os.path.basename(info.filename) + targets = ["Inter-Regular.ttf", "Inter-Bold.ttf", "Inter-Italic.ttf", "Inter-BoldItalic.ttf"] + filename = os.path.basename(info.filename) + if filename in targets: + info.filename = filename z.extract(info, "assets/fonts/") print(f"Extracted {info.filename}") except Exception as e: print(f"Failed to get Inter: {e}") + +maple_url = "https://github.com/subframe7536/maple-font/releases/download/v6.4/MapleMono-ttf.zip" +print(f"Downloading Maple Mono from {maple_url}") +try: + req = urllib.request.Request(maple_url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response: + with zipfile.ZipFile(io.BytesIO(response.read())) as z: + for info in z.infolist(): + targets = ["MapleMono-Regular.ttf", "MapleMono-Bold.ttf", "MapleMono-Italic.ttf", "MapleMono-BoldItalic.ttf"] + filename = os.path.basename(info.filename) + if filename in targets: + info.filename = filename + z.extract(info, "assets/fonts/") + print(f"Extracted {info.filename}") +except Exception as e: + print(f"Failed to get Maple Mono: {e}") diff --git a/scripts/tasks/test_link.py b/scripts/tasks/test_link.py new file mode 100644 index 0000000..860654d --- /dev/null +++ b/scripts/tasks/test_link.py @@ -0,0 +1,35 @@ +import os +import subprocess +import shutil +from pathlib import Path + +def test_link(): + project_root = Path(os.getcwd()) + temp_workspace = project_root / "tests" / "artifacts" / "test_link_workspace" + if temp_workspace.exists(): + shutil.rmtree(temp_workspace) + temp_workspace.mkdir(parents=True, exist_ok=True) + + src_assets = project_root / "assets" + dest_assets = temp_workspace / "assets" + + print(f"Linking {src_assets} to {dest_assets}") + if os.name == 'nt': + res = subprocess.run(["cmd", "/c", "mklink", "/D", str(dest_assets), str(src_assets)], capture_output=True, text=True) + print(f"Exit code: {res.returncode}") + print(f"Stdout: {res.stdout}") + print(f"Stderr: {res.stderr}") + else: + os.symlink(src_assets, dest_assets) + + if dest_assets.exists(): + print("Link exists") + if (dest_assets / "fonts" / "Inter-Regular.ttf").exists(): + print("Font file accessible via link") + else: + print("Font file NOT accessible") + else: + print("Link does NOT exist") + +if __name__ == "__main__": + test_link() diff --git a/scripts/tasks/test_markdown.py b/scripts/tasks/test_markdown.py new file mode 100644 index 0000000..660a3f3 --- /dev/null +++ b/scripts/tasks/test_markdown.py @@ -0,0 +1,40 @@ +from imgui_bundle import imgui, immapp, imgui_md +import sys + +def gui(): + imgui.text("Markdown Test") + imgui.separator() + + md = """ +# Header 1 +## Header 2 +This is **bold** and *italic*. + +* List item 1 +* List list 2 + +[Google](https://google.com) + +```python +def hello(): + print("world") +``` + +
This is inside a div
+""" + imgui_md.render(md) + +def on_html_div(div_class: str, opening: bool): + print(f"HTML DIV: class={div_class}, opening={opening}") + if opening: + imgui.push_style_color(imgui.Col_.text.value, imgui.ImColor(255, 0, 0, 255).value) + else: + imgui.pop_style_color() + +def main(): + options = imgui_md.MarkdownOptions() + options.callbacks.on_html_div = on_html_div + immapp.run(gui, with_markdown_options=options, window_size=(600, 600)) + +if __name__ == "__main__": + main() diff --git a/src/app_controller.py b/src/app_controller.py index fed0a92..d7324b1 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -799,7 +799,6 @@ class AppController: "Tool Calls": False, "Theme": True, "Log Management": False, - "Markdown Test": False, } saved = self.config.get("gui", {}).get("show_windows", {}) self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()} diff --git a/src/gui_2.py b/src/gui_2.py index 76cca24..ddb158b 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -202,10 +202,10 @@ class App: self.text_viewer_title = label self.text_viewer_content = content - def _render_heavy_text(self, label: str, content: str) -> None: + def _render_heavy_text(self, label: str, content: str, id_suffix: str = "") -> None: imgui.text_colored(C_LBL, f"{label}:") imgui.same_line() - if imgui.button("[+]##" + label): + if imgui.button("[+]##" + label + id_suffix): self.show_text_viewer = True self.text_viewer_title = label self.text_viewer_content = content @@ -214,13 +214,21 @@ class App: imgui.text_disabled("(empty)") return + is_md = label in ("message", "text", "content") + ctx_id = f"heavy_{label}_{id_suffix}" + if len(content) > COMMS_CLAMP_CHARS: - # Use a fixed-height child window with unformatted text for large text to avoid expensive frame-by-frame wrapping or input_text_multiline overhead - imgui.begin_child(f"heavy_text_child_{label}_{hash(content)}", imgui.ImVec2(0, 80), True) - self._render_selectable_label(f'heavy_val_{label}_{hash(content)}', content, width=-1, multiline=True, height=80) + imgui.begin_child(f"heavy_text_child_{label}_{id_suffix}", imgui.ImVec2(0, 80), True) + if is_md: + markdown_helper.render(content, context_id=ctx_id) + else: + markdown_helper.render_code(content, context_id=ctx_id) imgui.end_child() else: - self._render_selectable_label(f'heavy_val_{label}_{hash(content)}', content, width=-1, multiline=self.ui_word_wrap, height=0) + if is_md: + markdown_helper.render(content, context_id=ctx_id) + else: + markdown_helper.render_code(content, context_id=ctx_id) # ---------------------------------------------------------------- gui @@ -521,13 +529,6 @@ class App: if self.show_windows.get("Diagnostics", False): self._render_diagnostics_panel() - if self.show_windows.get("Markdown Test", False): - exp, opened = imgui.begin("Markdown Test", self.show_windows["Markdown Test"]) - self.show_windows["Markdown Test"] = bool(opened) - if exp: - self._render_markdown_test() - imgui.end() - self.perf_monitor.end_frame() # ---- Modals / Popups with self._pending_dialog_lock: @@ -1387,7 +1388,7 @@ def hello(): if len(content) > 80: preview += "..." imgui.text_colored(vec4(180, 180, 180), preview) else: - self._render_selectable_label(f'prior_content_val_{idx}', content, width=-1, multiline=True, height=150) + markdown_helper.render(content, context_id=f'prior_disc_{idx}') imgui.separator() imgui.pop_id() @@ -1546,14 +1547,14 @@ def hello(): pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?") matches = list(pattern.finditer(content)) if not matches: - self._render_selectable_label(f'read_content_{i}', content, width=-1, multiline=True, height=150) + markdown_helper.render(content, context_id=f'disc_{i}') else: - imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) + imgui.begin_child(f"read_content_{i}", imgui.ImVec2(0, 150), True) if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) last_idx = 0 for m_idx, match in enumerate(matches): before = content[last_idx:match.start()] - if before: self._render_selectable_label(f'read_before_{i}_{m_idx}', before, width=-1, multiline=True, height=0) + if before: markdown_helper.render(before, context_id=f'disc_{i}_b_{m_idx}') header_text = match.group(0).split("\n")[0].strip() path = match.group(2) code_block = match.group(4) @@ -1565,16 +1566,11 @@ def hello(): self.text_viewer_content = res self.show_text_viewer = True if code_block: - code_content = code_block.strip() - if code_content.count("\n") + 1 > 50: - imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True) - imgui.text(code_content) - imgui.end_child() - else: - imgui.text(code_content) + # Render code block with highlighting + markdown_helper.render(code_block, context_id=f'disc_{i}_c_{m_idx}') last_idx = match.end() after = content[last_idx:] - if after: self._render_selectable_label(f'read_after_{i}_{last_idx}', after, width=-1, multiline=True, height=0) + if after: markdown_helper.render(after, context_id=f'disc_{i}_a') if self.ui_word_wrap: imgui.pop_text_wrap_pos() imgui.end_child() else: @@ -1892,7 +1888,7 @@ def hello(): # --- Always Render Content --- imgui.begin_child("response_scroll_area", imgui.ImVec2(0, -40), True) - imgui.input_text_multiline("##ai_out", self.ai_response, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) + markdown_helper.render(self.ai_response, context_id="response") imgui.end_child() imgui.separator() @@ -1981,24 +1977,25 @@ def hello(): imgui.text_colored(C_SUB, f"[{tier}]") # Optimized content rendering using _render_heavy_text logic + idx_str = str(i) if kind == "request": - self._render_heavy_text("message", payload.get("message", "")) + self._render_heavy_text("message", payload.get("message", ""), idx_str) if payload.get("system"): - self._render_heavy_text("system", payload.get("system", "")) + self._render_heavy_text("system", payload.get("system", ""), idx_str) elif kind == "response": r = payload.get("round", 0) sr = payload.get("stop_reason", "STOP") imgui.text_colored(C_LBL, f"round: {r} stop_reason: {sr}") - self._render_heavy_text("text", payload.get("text", "")) + self._render_heavy_text("text", payload.get("text", ""), idx_str) tcs = payload.get("tool_calls", []) if tcs: - self._render_heavy_text("tool_calls", json.dumps(tcs, indent=1)) + self._render_heavy_text("tool_calls", json.dumps(tcs, indent=1), idx_str) elif kind == "tool_call": - self._render_heavy_text(payload.get("name", "call"), payload.get("script") or json.dumps(payload.get("args", {}), indent=1)) + self._render_heavy_text(payload.get("name", "call"), payload.get("script") or json.dumps(payload.get("args", {}), indent=1), idx_str) elif kind == "tool_result": - self._render_heavy_text(payload.get("name", "result"), payload.get("output", "")) + self._render_heavy_text(payload.get("name", "result"), payload.get("output", ""), idx_str) else: - self._render_heavy_text("data", str(payload)) + self._render_heavy_text("data", str(payload), idx_str) imgui.separator() imgui.pop_id() diff --git a/src/markdown_helper.py b/src/markdown_helper.py index 1ec5da2..1f5c101 100644 --- a/src/markdown_helper.py +++ b/src/markdown_helper.py @@ -1,46 +1,155 @@ # src/markdown_helper.py from __future__ import annotations -from imgui_bundle import imgui_md, imgui, immapp +from imgui_bundle import imgui_md, imgui, immapp, imgui_color_text_edit as ed import webbrowser import os -from typing import Optional +import re +from pathlib import Path +from typing import Optional, Dict, Callable class MarkdownRenderer: """ - Wrapper for imgui_md to manage styling, callbacks, and specialized rendering - (like syntax highlighting integration). + Hybrid Markdown renderer that uses imgui_md for text/headers + and ImGuiColorTextEdit for syntax-highlighted code blocks. """ def __init__(self): self.options = imgui_md.MarkdownOptions() - # Use Inter as the base font for Markdown (matches professional theme) - # It expects fonts like Inter-Regular.ttf, Inter-Bold.ttf, etc. in the assets folder + # Base path for fonts (Inter family) self.options.font_options.font_base_path = "fonts/Inter" self.options.font_options.regular_size = 16.0 # Configure callbacks self.options.callbacks.on_open_link = self._on_open_link - # Note: Syntax highlighting will be integrated in Phase 2 + # Cache for TextEditor instances to maintain state + self._editor_cache: Dict[tuple[str, int], ed.TextEditor] = {} + self._max_cache_size = 100 + + # Optional callback for custom local link handling (e.g., opening in IDE) + self.on_local_link: Optional[Callable[[str], None]] = None + + # Language mapping for ImGuiColorTextEdit + self._lang_map = { + "python": ed.TextEditor.LanguageDefinitionId.python, + "py": ed.TextEditor.LanguageDefinitionId.python, + "json": ed.TextEditor.LanguageDefinitionId.json, + "cpp": ed.TextEditor.LanguageDefinitionId.cpp, + "c++": ed.TextEditor.LanguageDefinitionId.cpp, + "c": ed.TextEditor.LanguageDefinitionId.c, + "lua": ed.TextEditor.LanguageDefinitionId.lua, + "sql": ed.TextEditor.LanguageDefinitionId.sql, + "cs": ed.TextEditor.LanguageDefinitionId.cs, + "c#": ed.TextEditor.LanguageDefinitionId.cs, + } def _on_open_link(self, url: str) -> None: """Handle link clicks in Markdown.""" - # If it's a URL, open in browser if url.startswith("http"): webbrowser.open(url) else: - # Handle local files or internal links - # For now, just print. Could integrate with app_controller in Phase 4. - print(f"Clicked local link: {url}") + # Try to handle as a local file path + try: + p = Path(url) + if p.exists(): + if self.on_local_link: + self.on_local_link(str(p.absolute())) + else: + # Fallback to OS default handler + webbrowser.open(str(p.absolute())) + else: + print(f"Link target does not exist: {url}") + except Exception as e: + print(f"Error opening link {url}: {e}") - def render(self, text: str) -> None: - """Render Markdown text using imgui_md.""" - imgui_md.render(text) + def render(self, text: str, context_id: str = "default") -> None: + """Render Markdown text with code block interception.""" + if not text: + return + + # Split into markdown and code blocks + parts = re.split(r'(```[\s\S]*?```)', text) + + block_idx = 0 + for part in parts: + if part.startswith('```') and part.endswith('```'): + self._render_code_block(part, context_id, block_idx) + block_idx += 1 + elif part.strip(): + imgui_md.render(part) def render_unindented(self, text: str) -> None: """Render Markdown text with automatic unindentation.""" imgui_md.render_unindented(text) -# Global instance for easy access + def render_code(self, code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None: + """Render a code block directly with syntax highlighting.""" + # Wrap in fake markdown markers for the internal renderer + self._render_code_block(f"```{lang}\n{code}```", context_id, block_idx) + + def _render_code_block(self, block: str, context_id: str, block_idx: int) -> None: + """Render a code block using TextEditor for syntax highlighting.""" + lines = block.strip('`').split('\n') + lang_tag = lines[0].strip().lower() if lines else "" + + # Heuristic to separate lang tag from code + if lang_tag and lang_tag not in self._lang_map and not self._is_likely_lang_tag(lang_tag): + lang_tag = "" + code = '\n'.join(lines) + else: + code = '\n'.join(lines[1:]) if len(lines) > 1 else "" + + if not lang_tag: + lang_tag = self.detect_language(code) + + # Cache management + if len(self._editor_cache) > self._max_cache_size: + # Simple LRU-ish: just clear it all if it gets too big + self._editor_cache.clear() + + cache_key = (context_id, block_idx) + if cache_key not in self._editor_cache: + editor = ed.TextEditor() + editor.set_read_only_enabled(True) + editor.set_show_line_numbers_enabled(True) + self._editor_cache[cache_key] = editor + + editor = self._editor_cache[cache_key] + + # Sync text and language + lang_id = self._lang_map.get(lang_tag, ed.TextEditor.LanguageDefinitionId.none) + target_text = code + "\n" + + if editor.get_text() != target_text: + editor.set_text(code) + editor.set_language_definition(lang_id) + elif editor.get_language_definition_name().lower() != lang_tag: + # get_language_definition_name might not match exactly but good enough check + editor.set_language_definition(lang_id) + + # Dynamic height calculation + line_count = code.count('\n') + 1 + line_height = imgui.get_text_line_height() + height = (line_count * line_height) + 20 + height = min(max(height, 40), 500) + + editor.render(f"##code_{context_id}_{block_idx}", a_size=imgui.ImVec2(0, height)) + + def _is_likely_lang_tag(self, tag: str) -> bool: + return bool(re.match(r'^[a-zA-Z0-9+#-]+$', tag)) and len(tag) < 15 + + def detect_language(self, code: str) -> str: + if "def " in code or "import " in code: + return "python" + if "{" in code and '"' in code and ":" in code: + return "json" + if "$" in code and ("{" in code or "if" in code): + return "powershell" + return "" + + def clear_cache(self) -> None: + self._editor_cache.clear() + +# Global instance _renderer: Optional[MarkdownRenderer] = None def get_renderer() -> MarkdownRenderer: @@ -49,8 +158,11 @@ def get_renderer() -> MarkdownRenderer: _renderer = MarkdownRenderer() return _renderer -def render(text: str) -> None: - get_renderer().render(text) +def render(text: str, context_id: str = "default") -> None: + get_renderer().render(text, context_id) def render_unindented(text: str) -> None: get_renderer().render_unindented(text) + +def render_code(code: str, lang: str = "", context_id: str = "default", block_idx: int = 0) -> None: + get_renderer().render_code(code, lang, context_id, block_idx) diff --git a/src/shaders.py b/src/shaders.py index a7d0e00..c5fff4c 100644 --- a/src/shaders.py +++ b/src/shaders.py @@ -32,7 +32,7 @@ def draw_soft_shadow(draw_list: imgui.ImDrawList, p_min: imgui.ImVec2, p_max: im c_max, u32_color, rounding + expand if rounding > 0 else 0.0, - flags=imgui.DrawFlags_.round_corners_all if rounding > 0 else imgui.DrawFlags_.none, + flags=imgui.ImDrawFlags_.round_corners_all if rounding > 0 else imgui.ImDrawFlags_.none, thickness=1.0 ) @@ -47,7 +47,7 @@ def apply_faux_acrylic_glass(draw_list: imgui.ImDrawList, p_min: imgui.ImVec2, p fill_color = imgui.get_color_u32(imgui.ImVec4(r, g, b, a * 0.7)) draw_list.add_rect_filled( p_min, p_max, fill_color, rounding, - flags=imgui.DrawFlags_.round_corners_all if rounding > 0 else imgui.DrawFlags_.none + flags=imgui.ImDrawFlags_.round_corners_all if rounding > 0 else imgui.ImDrawFlags_.none ) # 2. Gradient overlay to simulate light scattering (acrylic reflection) @@ -67,6 +67,6 @@ def apply_faux_acrylic_glass(draw_list: imgui.ImDrawList, p_min: imgui.ImVec2, p imgui.ImVec2(p_min.x + 1, p_min.y + 1), imgui.ImVec2(p_max.x - 1, p_max.y - 1), inner_glow, rounding, - flags=imgui.DrawFlags_.round_corners_all if rounding > 0 else imgui.DrawFlags_.none, + flags=imgui.ImDrawFlags_.round_corners_all if rounding > 0 else imgui.ImDrawFlags_.none, thickness=1.0 ) diff --git a/tests/conftest.py b/tests/conftest.py index d3870f1..7df03f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -216,6 +216,14 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]: if layout_file.exists(): shutil.copy2(layout_file, temp_workspace / layout_file.name) + # Link assets for fonts + src_assets = project_root / "assets" + if src_assets.exists(): + if os.name == 'nt': + subprocess.run(["cmd", "/c", "mklink", "/D", str(temp_workspace / "assets"), str(src_assets)], check=False) + else: + os.symlink(src_assets, temp_workspace / "assets") + # Check if already running (shouldn't be) try: resp = requests.get("http://127.0.0.1:8999/status", timeout=0.5) diff --git a/tests/test_gui_phase4.py b/tests/test_gui_phase4.py index 4b91284..bdc3ba4 100644 --- a/tests/test_gui_phase4.py +++ b/tests/test_gui_phase4.py @@ -90,7 +90,7 @@ def test_track_discussion_toggle(mock_app: App): mock_imgui.selectable.return_value = (False, False) mock_imgui.button.return_value = False mock_imgui.collapsing_header.return_value = True # For Discussions header - mock_imgui.input_text.side_effect = lambda label, value, **kwargs: (False, value) + mock_imgui.input_text.side_effect = lambda label, value, *args, **kwargs: (False, value) mock_imgui.input_int.side_effect = lambda label, value, *args, **kwargs: (False, value) mock_imgui.begin_child.return_value = True # Mock clipper to avoid the while loop hang diff --git a/tests/test_gui_progress.py b/tests/test_gui_progress.py index 86f9e8e..f8b2f3e 100644 --- a/tests/test_gui_progress.py +++ b/tests/test_gui_progress.py @@ -39,6 +39,8 @@ def test_render_mma_dashboard_progress(): Ticket(id='T4', description='desc', status='todo') ] + app.is_viewing_prior_session = False + app.perf_profiling_enabled = False app.mma_tier_usage = {} app.mma_status = "idle" app.active_tier = None diff --git a/tests/test_mma_approval_indicators.py b/tests/test_mma_approval_indicators.py index 1c26ae8..96d636a 100644 --- a/tests/test_mma_approval_indicators.py +++ b/tests/test_mma_approval_indicators.py @@ -35,6 +35,7 @@ def _make_app(**kwargs): app.ui_new_ticket_deps = "" app.ui_new_ticket_deps = "" app.ui_selected_ticket_id = "" + app.is_viewing_prior_session = False mock_engine = MagicMock() mock_engine._pause_event = MagicMock() mock_engine._pause_event.is_set.return_value = False diff --git a/tests/test_mma_dashboard_streams.py b/tests/test_mma_dashboard_streams.py index 596dd03..0cbf756 100644 --- a/tests/test_mma_dashboard_streams.py +++ b/tests/test_mma_dashboard_streams.py @@ -34,6 +34,7 @@ def _make_app(**kwargs): app.ui_new_ticket_target = "" app.ui_new_ticket_deps = "" app._tier_stream_last_len = {} + app.is_viewing_prior_session = False mock_engine = MagicMock() mock_engine._pause_event = MagicMock() mock_engine._pause_event.is_set.return_value = False @@ -65,8 +66,8 @@ class TestMMADashboardStreams: imgui_mock.begin_child.return_value = True with patch("src.gui_2.imgui", imgui_mock): App._render_tier_stream_panel(app, "Tier 1", "Tier 1") - text_wrapped_args = " ".join(str(c) for c in imgui_mock.text_wrapped.call_args_list) - assert "hello" in text_wrapped_args, "text_wrapped not called with stream content 'hello'" + + app._render_selectable_label.assert_called_with('stream_Tier 1', 'hello', width=-1, multiline=True, height=0) def test_tier3_renders_worker_subheaders(self): """_render_tier_stream_panel for Tier 3 must render a sub-header for each worker stream key."""