Skip to content

Commit b617550

Browse files
committed
fix(tools): harden todo renderer and blank-title normalization
Treat blank titles as missing for Cursor-shape detection, compute indent from ANSI-stripped text, and show invalid args for malformed complete lists.
1 parent e840d84 commit b617550

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

src/pythinker_code/tools/todo/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]:
2525
"""Accept Cursor/Claude TodoWrite shape while keeping internal state canonical.
2626
2727
Supported external aliases:
28-
- ``content`` -> ``title``, only when ``title`` is missing
28+
- ``content`` -> ``title``, only when ``title`` is missing or blank
2929
3030
Deliberately does not:
3131
- invent titles
@@ -48,8 +48,9 @@ def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]:
4848

4949
title = item.get("title")
5050
content = item.get("content")
51+
title_missing = title is None or (isinstance(title, str) and not title.strip())
5152

52-
if title is None and content is not None:
53+
if title_missing and content is not None:
5354
item["title"] = content
5455

5556
item.pop("content", None)

src/pythinker_code/ui/shell/tool_renderers/todo.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,22 @@
5252

5353

5454
def _has_cursor_todowrite_shape(args: dict[str, Any]) -> bool:
55-
"""True when args look like Cursor/Claude TodoWrite ({content} without {title})."""
55+
"""True when args look like Cursor/Claude TodoWrite ({content} without usable {title})."""
5656
todos = args.get("todos")
5757
if not isinstance(todos, list):
5858
return False
59+
5960
for raw in cast("list[Any]", todos):
6061
if not isinstance(raw, dict):
6162
continue
63+
6264
item = cast(dict[str, Any], raw)
63-
if "content" in item and "title" not in item:
65+
title = (as_str(item.get("title")) or "").strip()
66+
content = (as_str(item.get("content")) or "").strip()
67+
68+
if content and not title:
6469
return True
70+
6571
return False
6672

6773

@@ -102,13 +108,15 @@ def _clean_todo_title(raw_title: str) -> str:
102108
def _todo_level_and_title(item: dict[str, Any]) -> tuple[int, str]:
103109
"""Return display nesting level and a cleaned title."""
104110
raw_title = as_str(item.get("title")) or as_str(item.get("content")) or ""
111+
safe_title = sanitize_ansi(raw_title)
112+
105113
explicit = item.get("level", item.get("depth", item.get("indent")))
106-
cleaned = _clean_todo_title(raw_title)
114+
cleaned = _clean_todo_title(safe_title)
107115

108116
if isinstance(explicit, int):
109117
return max(0, min(explicit, 6)), cleaned
110118

111-
leading_spaces = len(raw_title) - len(raw_title.lstrip(" "))
119+
leading_spaces = len(safe_title) - len(safe_title.lstrip(" "))
112120
level = max(0, min(leading_spaces // 2, 6))
113121
return level, cleaned
114122

@@ -162,6 +170,15 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType:
162170
)
163171

164172
todos_list = cast("list[Any]", todos)
173+
has_malformed_items = any(not isinstance(t, dict) for t in todos_list)
174+
if has_malformed_items and (ctx.args_complete or ctx.has_result):
175+
header = tool_call_header("todos", invalid_arg(), style_token=style_token)
176+
return running_spinner(
177+
header,
178+
execution_started=ctx.execution_started,
179+
has_result=ctx.has_result,
180+
)
181+
165182
items: list[dict[str, Any]] = [
166183
cast("dict[str, Any]", t) for t in todos_list if isinstance(t, dict)
167184
]

tests/tools/test_todo.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,22 @@ def test_normalizer_does_not_mutate_input(self):
6262
normalize_set_todo_list_args(args)
6363
assert args == original
6464

65+
def test_blank_title_with_content_normalizes_to_title(self):
66+
args = {
67+
"todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}]
68+
}
69+
out = normalize_set_todo_list_args(args)
70+
assert out["todos"][0]["title"] == "Install framer-motion"
71+
assert "content" not in out["todos"][0]
72+
73+
def test_params_mixed_non_dict_item_fails_validation(self):
74+
from pydantic import ValidationError
75+
76+
with pytest.raises(ValidationError):
77+
Params( # type: ignore[arg-type]
78+
todos=[{"title": "ok", "status": "pending"}, "bad"]
79+
)
80+
6581
def test_missing_title_still_fails_after_normalization(self):
6682
args = {"todos": [{"id": "1", "status": "pending"}]}
6783
out = normalize_set_todo_list_args(args)

tests/ui_and_conv/test_tui_card_tool_renderers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from pythinker_code.ui.shell.tool_renderers.generic import generic_renderer
3232
from pythinker_code.ui.shell.tool_renderers.todo import (
3333
TODO_RENDERER,
34+
_has_cursor_todowrite_shape,
3435
_summarize_todo_validation_error,
3536
_todo_level_and_title,
3637
)
@@ -1200,6 +1201,43 @@ def test_todo_validation_error_shows_full_detail_when_expanded():
12001201
assert "Field required" in rendered
12011202

12021203

1204+
def test_cursor_shape_detects_blank_title_with_content():
1205+
args = {"todos": [{"title": "", "content": "Install framer-motion", "status": "pending"}]}
1206+
assert _has_cursor_todowrite_shape(args) is True
1207+
1208+
1209+
def test_renderer_blank_title_uses_content_fallback():
1210+
_level, title = _todo_level_and_title(
1211+
{"title": "", "content": "Install framer-motion", "status": "pending"}
1212+
)
1213+
assert title == "Install framer-motion"
1214+
1215+
1216+
def test_renderer_indent_ignores_ansi_before_leading_spaces():
1217+
raw = "\x1b[31m \x1b[0mNested task"
1218+
_level, title = _todo_level_and_title({"title": raw, "status": "pending"})
1219+
assert _level == 1
1220+
assert title == "Nested task"
1221+
1222+
1223+
def test_malformed_todos_list_renders_invalid_when_args_complete():
1224+
rendered = _render_running(
1225+
"SetTodoList",
1226+
{"todos": ["bad", None, 123, {"title": "ok", "status": "pending"}]},
1227+
)
1228+
assert "<invalid>" in rendered
1229+
assert "ok" not in rendered
1230+
1231+
1232+
def test_malformed_todos_streaming_skips_non_dict_items():
1233+
rendered = _render_streaming(
1234+
"SetTodoList",
1235+
{"todos": ["bad", {"title": "Visible", "status": "pending"}]},
1236+
)
1237+
assert "<invalid>" not in rendered
1238+
assert "Visible" in rendered
1239+
1240+
12031241
# ---------------------------------------------------------------------------
12041242
# Web
12051243
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)