fix(markdown): rewrite render() to walk lines (no text replacement)
ROOT CAUSE: src/markdown_helper.py:render() used a 'mask text with placeholders then re.split' approach that failed when AI responses contained CRLF or when the same table content appeared twice. The replace() either didn't match (CRLF mismatch) or only replaced the first occurrence, leaving the second table as raw markdown for imgui_md to render badly. Result: the same table appeared twice (bad rendering via imgui_md, good rendering via my new code). FIX: rewrite render() to walk lines directly. Per-line, decide whether to buffer for imgui_md, skip into a table renderer, or accumulate into a code-block renderer. No text replacement needed. - src/markdown_helper.py: new render() walks lines, handles code fences and table intervals inline via lookup dicts. - src/gui_2.py: render_log_management now calls load_registry() on the newly-created LogRegistry when _log_registry was None. Previously the initial construction populated an empty table, AND the 'Refresh Registry' button was inside the else branch, so users had no way to load data. User re-indented the surrounding block during debugging. Tests: - test_markdown_render_robust.py: 2 tests (CRLF text, duplicate content) - test_log_management_first_open.py: 1 test (registry populated on open) 40/40 broad regression pass.
This commit is contained in:
+65
-64
@@ -1671,71 +1671,71 @@ def render_log_management(app: App) -> None:
|
||||
if app.perf_profiling_enabled: app.perf_monitor.start_component("_render_log_management")
|
||||
with imscope.window("Log Management", app.show_windows["Log Management"]) as (exp, opened):
|
||||
app.show_windows["Log Management"] = bool(opened)
|
||||
if exp:
|
||||
if app._log_registry is None: app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml"))
|
||||
else:
|
||||
if imgui.button("Refresh Registry"):
|
||||
if app._log_registry is not None: app._log_registry.load_registry()
|
||||
imgui.same_line()
|
||||
if imgui.button("Load Log"): app.cb_load_prior_log()
|
||||
imgui.same_line()
|
||||
if imgui.button("Force Prune Logs"): app.controller.event_queue.put("gui_task", {"action": "click", "item": "btn_prune_logs"})
|
||||
if app._log_registry is None:
|
||||
app._log_registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml"))
|
||||
app._log_registry.load_registry()
|
||||
if imgui.button("Refresh Registry"):
|
||||
if app._log_registry is not None: app._log_registry.load_registry()
|
||||
imgui.same_line()
|
||||
if imgui.button("Load Log"): app.cb_load_prior_log()
|
||||
imgui.same_line()
|
||||
if imgui.button("Force Prune Logs"): app.controller.event_queue.put("gui_task", {"action": "click", "item": "btn_prune_logs"})
|
||||
|
||||
registry = app._log_registry
|
||||
sessions = registry.data
|
||||
if imgui.begin_table("sessions_table", 7, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable):
|
||||
imgui.table_setup_column("Session ID")
|
||||
imgui.table_setup_column("Start Time")
|
||||
imgui.table_setup_column("Star")
|
||||
imgui.table_setup_column("Reason")
|
||||
imgui.table_setup_column("Size (KB)")
|
||||
imgui.table_setup_column("Msgs")
|
||||
imgui.table_setup_column("Actions")
|
||||
imgui.table_headers_row()
|
||||
for session_id, s_data in sessions.items():
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text(session_id)
|
||||
imgui.table_next_column()
|
||||
imgui.text(s_data.get("start_time", ""))
|
||||
imgui.table_next_column()
|
||||
whitelisted = s_data.get("whitelisted", False)
|
||||
if whitelisted:
|
||||
imgui.text_colored(vec4(255, 215, 0), "YES")
|
||||
else:
|
||||
imgui.text("NO")
|
||||
metadata = s_data.get("metadata") or {}
|
||||
imgui.table_next_column()
|
||||
imgui.text(metadata.get("reason", ""))
|
||||
imgui.table_next_column()
|
||||
imgui.text(str(metadata.get("size_kb", "")))
|
||||
imgui.table_next_column()
|
||||
imgui.text(str(metadata.get("message_count", "")))
|
||||
imgui.table_next_column()
|
||||
if imgui.button(f"Load##{session_id}"):
|
||||
app.cb_load_prior_log(s_data.get("path"))
|
||||
imgui.same_line()
|
||||
if whitelisted:
|
||||
if imgui.button(f"Unstar##{session_id}"):
|
||||
registry.update_session_metadata(
|
||||
session_id,
|
||||
message_count=int(metadata.get("message_count") or 0),
|
||||
errors=int(metadata.get("errors") or 0),
|
||||
size_kb=int(metadata.get("size_kb") or 0),
|
||||
whitelisted=False,
|
||||
reason=str(metadata.get("reason") or "")
|
||||
)
|
||||
else:
|
||||
if imgui.button(f"Star##{session_id}"):
|
||||
registry.update_session_metadata(
|
||||
session_id,
|
||||
message_count=int(metadata.get("message_count") or 0),
|
||||
errors=int(metadata.get("errors") or 0),
|
||||
size_kb=int(metadata.get("size_kb") or 0),
|
||||
whitelisted=True,
|
||||
reason="Manually whitelisted"
|
||||
)
|
||||
imgui.end_table()
|
||||
registry = app._log_registry
|
||||
sessions = registry.data
|
||||
if imgui.begin_table("sessions_table", 7, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable):
|
||||
imgui.table_setup_column("Session ID")
|
||||
imgui.table_setup_column("Start Time")
|
||||
imgui.table_setup_column("Star")
|
||||
imgui.table_setup_column("Reason")
|
||||
imgui.table_setup_column("Size (KB)")
|
||||
imgui.table_setup_column("Msgs")
|
||||
imgui.table_setup_column("Actions")
|
||||
imgui.table_headers_row()
|
||||
for session_id, s_data in sessions.items():
|
||||
imgui.table_next_row()
|
||||
imgui.table_next_column()
|
||||
imgui.text(session_id)
|
||||
imgui.table_next_column()
|
||||
imgui.text(s_data.get("start_time", ""))
|
||||
imgui.table_next_column()
|
||||
whitelisted = s_data.get("whitelisted", False)
|
||||
if whitelisted:
|
||||
imgui.text_colored(vec4(255, 215, 0), "YES")
|
||||
else:
|
||||
imgui.text("NO")
|
||||
metadata = s_data.get("metadata") or {}
|
||||
imgui.table_next_column()
|
||||
imgui.text(metadata.get("reason", ""))
|
||||
imgui.table_next_column()
|
||||
imgui.text(str(metadata.get("size_kb", "")))
|
||||
imgui.table_next_column()
|
||||
imgui.text(str(metadata.get("message_count", "")))
|
||||
imgui.table_next_column()
|
||||
if imgui.button(f"Load##{session_id}"):
|
||||
app.cb_load_prior_log(s_data.get("path"))
|
||||
imgui.same_line()
|
||||
if whitelisted:
|
||||
if imgui.button(f"Unstar##{session_id}"):
|
||||
registry.update_session_metadata(
|
||||
session_id,
|
||||
message_count=int(metadata.get("message_count") or 0),
|
||||
errors=int(metadata.get("errors") or 0),
|
||||
size_kb=int(metadata.get("size_kb") or 0),
|
||||
whitelisted=False,
|
||||
reason=str(metadata.get("reason") or "")
|
||||
)
|
||||
else:
|
||||
if imgui.button(f"Star##{session_id}"):
|
||||
registry.update_session_metadata(
|
||||
session_id,
|
||||
message_count=int(metadata.get("message_count") or 0),
|
||||
errors=int(metadata.get("errors") or 0),
|
||||
size_kb=int(metadata.get("size_kb") or 0),
|
||||
whitelisted=True,
|
||||
reason="Manually whitelisted"
|
||||
)
|
||||
imgui.end_table()
|
||||
|
||||
if app.perf_profiling_enabled: app.perf_monitor.end_component("_render_log_management")
|
||||
|
||||
@@ -3463,6 +3463,7 @@ def render_discussion_entry(app: App, entry: dict, index: int) -> None:
|
||||
imgui.same_line()
|
||||
if imgui.button("Del"):
|
||||
if entry in app.disc_entries: app.disc_entries.remove(entry)
|
||||
imgui.end_group()
|
||||
draw_list.channels_merge()
|
||||
return
|
||||
imgui.same_line()
|
||||
|
||||
+61
-20
@@ -117,30 +117,71 @@ class MarkdownRenderer:
|
||||
|
||||
from src.markdown_table import parse_tables, render_table
|
||||
blocks = parse_tables(text)
|
||||
sentinel = "\x00TBL{}\x00"
|
||||
masked = text
|
||||
for idx, block in enumerate(blocks):
|
||||
start, end = block.span
|
||||
original_block = "\n".join(masked.splitlines()[start:end])
|
||||
masked = masked.replace(original_block, sentinel.format(idx), 1)
|
||||
lines = text.splitlines(keepends=True)
|
||||
if not lines:
|
||||
return
|
||||
|
||||
parts = re.split(r"(```[\s\S]*?```)", masked)
|
||||
table_at_line: dict[int, int] = {b.span[0]: i for i, b in enumerate(blocks)}
|
||||
table_end: dict[int, int] = {b.span[0]: b.span[1] for i, b in enumerate(blocks)}
|
||||
|
||||
md_buf: list[str] = []
|
||||
code_buf: list[str] = []
|
||||
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():
|
||||
sub_parts = re.split(r"(\x00TBL\d+\x00)", part)
|
||||
for sp in sub_parts:
|
||||
if sp.startswith("\x00TBL") and sp.endswith("\x00"):
|
||||
tbl_idx = int(sp[4:-1])
|
||||
try: render_table(blocks[tbl_idx])
|
||||
except Exception: imgui.text(sp)
|
||||
else:
|
||||
if sp.strip(): imgui_md.render(sp)
|
||||
in_fence = False
|
||||
fence_marker = ""
|
||||
|
||||
def flush_md() -> None:
|
||||
if md_buf:
|
||||
chunk = "".join(md_buf)
|
||||
if chunk.strip():
|
||||
imgui_md.render(chunk)
|
||||
md_buf.clear()
|
||||
|
||||
def flush_code() -> None:
|
||||
nonlocal block_idx
|
||||
if code_buf:
|
||||
chunk = "".join(code_buf)
|
||||
self._render_code_block(chunk, context_id, block_idx)
|
||||
block_idx += 1
|
||||
code_buf.clear()
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip().rstrip("\r\n")
|
||||
if stripped.startswith("```"):
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_marker = stripped[:3]
|
||||
flush_md()
|
||||
code_buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if fence_marker and stripped.startswith(fence_marker):
|
||||
in_fence = False
|
||||
code_buf.append(line)
|
||||
flush_code()
|
||||
fence_marker = ""
|
||||
i += 1
|
||||
continue
|
||||
code_buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fence:
|
||||
code_buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if i in table_at_line:
|
||||
flush_md()
|
||||
try: render_table(blocks[table_at_line[i]])
|
||||
except Exception: pass
|
||||
i = table_end[i]
|
||||
continue
|
||||
md_buf.append(line)
|
||||
i += 1
|
||||
|
||||
flush_md()
|
||||
flush_code()
|
||||
def render_unindented(self, text: str) -> None:
|
||||
"""Render Markdown text with automatic unindentation."""
|
||||
imgui_md.render_unindented(text)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import os, tempfile, tomli_w
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.gui_2 import render_log_management
|
||||
|
||||
def test_log_management_populates_registry_on_first_open(app_instance):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
reg_path = os.path.join(tmp, "log_registry.toml")
|
||||
tomli_w.dump({"s1": {"start_time": "2026-06-03T10:00:00", "path": "C:/x/s1", "whitelisted": False}}, open(reg_path, "wb"))
|
||||
app_instance._log_registry = None
|
||||
app_instance.show_windows = {"Log Management": True}
|
||||
app_instance.perf_profiling_enabled = False
|
||||
with patch("src.gui_2.paths") as mock_paths, \
|
||||
patch("src.gui_2.imgui") as mock_imgui, \
|
||||
patch("src.gui_2.imscope") as mock_imscope:
|
||||
mock_paths.get_logs_dir.return_value = MagicMock()
|
||||
mock_paths.get_logs_dir.return_value.__truediv__ = lambda self, x: reg_path
|
||||
mock_imgui.collapsing_header = MagicMock(return_value=True)
|
||||
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
|
||||
mock_imgui.TableColumnFlags_ = type("C", (), {"width_fixed": 1, "width_stretch": 2})()
|
||||
mock_imgui.begin_table.return_value = True
|
||||
mock_imgui.button.return_value = False
|
||||
mock_imgui.table_next_row = lambda: None
|
||||
mock_imgui.table_next_column = lambda: None
|
||||
mock_imgui.text = lambda *a, **k: None
|
||||
mock_imgui.text_colored = lambda *a, **k: None
|
||||
mock_imgui.text_disabled = lambda *a, **k: None
|
||||
mock_imgui.end_table = lambda: None
|
||||
mock_imscope.window.return_value.__enter__.return_value = (True, True)
|
||||
app_instance.cb_load_prior_log = MagicMock()
|
||||
render_log_management(app_instance)
|
||||
assert app_instance._log_registry is not None
|
||||
assert "s1" in app_instance._log_registry.data, f"Registry should be populated on first open. Got: {list(app_instance._log_registry.data.keys())}"
|
||||
@@ -0,0 +1,56 @@
|
||||
from unittest.mock import patch
|
||||
from src.markdown_helper import MarkdownRenderer
|
||||
from src.markdown_table import parse_tables
|
||||
|
||||
def _mock_table_calls(mock_imgui):
|
||||
mock_imgui.TableFlags_ = type("T", (), {"borders": 1, "row_bg": 2, "resizable": 4})()
|
||||
mock_imgui.begin_table.return_value = True
|
||||
mock_imgui.table_next_column = lambda: None
|
||||
mock_imgui.table_next_row = lambda: None
|
||||
mock_imgui.table_headers_row = lambda: None
|
||||
mock_imgui.text = lambda *a, **k: None
|
||||
mock_imgui.end_table = lambda: None
|
||||
|
||||
def test_tables_in_crlf_text_all_get_masked():
|
||||
text = (
|
||||
"# Title\r\n"
|
||||
"\r\n"
|
||||
"| A | B |\r\n"
|
||||
"|---|---|\r\n"
|
||||
"| 1 | 2 |\r\n"
|
||||
"\r\n"
|
||||
"Some prose.\r\n"
|
||||
"\r\n"
|
||||
"| X | Y |\r\n"
|
||||
"|---|---|\r\n"
|
||||
"| 3 | 4 |\r\n"
|
||||
)
|
||||
blocks = parse_tables(text)
|
||||
assert len(blocks) == 2
|
||||
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
|
||||
_mock_table_calls(mock_table_imgui)
|
||||
MarkdownRenderer().render(text, context_id="t")
|
||||
full = "".join(str(c) for c in mock_md.render.call_args_list)
|
||||
for needle in ["| A | B |", "|---|", "| 1 | 2 |", "| X | Y |", "| 3 | 4 |"]:
|
||||
assert needle not in full, f"Raw table text leaked to imgui_md: {needle!r} in calls: {full!r}"
|
||||
|
||||
def test_duplicate_table_content_both_get_replaced():
|
||||
text = (
|
||||
"| A | B |\r\n"
|
||||
"|---|---|\r\n"
|
||||
"| 1 | 2 |\r\n"
|
||||
"\r\n"
|
||||
"Middle prose.\r\n"
|
||||
"\r\n"
|
||||
"| A | B |\r\n"
|
||||
"|---|---|\r\n"
|
||||
"| 1 | 2 |\r\n"
|
||||
)
|
||||
blocks = parse_tables(text)
|
||||
assert len(blocks) == 2
|
||||
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
|
||||
_mock_table_calls(mock_table_imgui)
|
||||
MarkdownRenderer().render(text, context_id="dup")
|
||||
full = "".join(str(c) for c in mock_md.render.call_args_list)
|
||||
assert "| A | B |" not in full, f"Raw table text leaked on duplicate: {full!r}"
|
||||
|
||||
Reference in New Issue
Block a user